Compare commits
10 Commits
2e4b771f96
...
7776af88d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7776af88d8 | ||
|
|
cbb1d85cef | ||
|
|
667cbc835d | ||
|
|
2340a65904 | ||
|
|
10abac3a3a | ||
|
|
8b26d346b7 | ||
|
|
da1bd30df0 | ||
|
|
6e868e51e4 | ||
|
|
5e03713fe9 | ||
|
|
ed8bcfabd6 |
72
app/Cron/FiveMinute/PirepPositionExpiration.php
Normal file
72
app/Cron/FiveMinute/PirepPositionExpiration.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Cron\FiveMinute;
|
||||
|
||||
use App\Contracts\Listener;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepState;
|
||||
use App\Events\CronFiveMinute;
|
||||
use App\Models\PirepPosition;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Decides what leaves the live map. Five-minutely, not hourly, because both of its
|
||||
* timers are in minutes.
|
||||
*/
|
||||
class PirepPositionExpiration extends Listener
|
||||
{
|
||||
public function handle(CronFiveMinute $event): void
|
||||
{
|
||||
$now = Carbon::now('UTC');
|
||||
|
||||
$liveTime = (int) setting('livemap.live_time');
|
||||
$idleTime = (int) setting('livemap.idle_time');
|
||||
|
||||
// Zero disables a timer rather than expiring everything instantly.
|
||||
if ($liveTime > 0) {
|
||||
// Finished, whatever phase the client last reported. Soft-deleted
|
||||
// PIREPs count, since RemoveExpiredLiveFlights leaves them that way.
|
||||
$this->evict(
|
||||
$now->copy()->subMinutes($liveTime),
|
||||
fn ($query) => $query->where(function ($q): void {
|
||||
$q->where('pireps.state', '<>', PirepState::IN_PROGRESS->value)
|
||||
->orWhereNotNull('pireps.deleted_at');
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if ($idleTime > 0) {
|
||||
// Paused, or prefiled and never departed. "Never departed" is
|
||||
// updated_at == created_at, since only a batch moves updated_at.
|
||||
$this->evict(
|
||||
$now->copy()->subMinutes($idleTime),
|
||||
fn ($query) => $query->where('pireps.state', PirepState::IN_PROGRESS->value)
|
||||
->whereNull('pireps.deleted_at')
|
||||
->where(function ($q): void {
|
||||
$q->where('pireps.status', PirepPhase::PAUSED->value)
|
||||
->orWhereColumn('pirep_positions.updated_at', '=', 'pirep_positions.created_at');
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clocked from pirep_positions.updated_at, not pireps.submitted_at - a pilot
|
||||
* who lands at 12:00 and files at 15:00 stopped flying at 12:00.
|
||||
*/
|
||||
private function evict(Carbon $before, callable $constrain): void
|
||||
{
|
||||
$ids = DB::table('pirep_positions')
|
||||
->join('pireps', 'pireps.id', '=', 'pirep_positions.pirep_id')
|
||||
->where('pirep_positions.updated_at', '<', $before)
|
||||
->tap($constrain)
|
||||
->pluck('pirep_positions.pirep_id');
|
||||
|
||||
// Re-checked, not taken on trust: a batch landing between the select and
|
||||
// the delete has refreshed updated_at and must survive.
|
||||
PirepPosition::whereIn('pirep_id', $ids)
|
||||
->where('updated_at', '<', $before)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Cron\Hourly;
|
||||
|
||||
use App\Contracts\Listener;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Events\CronHourly;
|
||||
use App\Models\Pirep;
|
||||
use App\Services\PirepService;
|
||||
@ -41,7 +41,7 @@ class DeletePireps extends Listener
|
||||
$dt = Carbon::now('UTC')->subHours($expire_time_hours);
|
||||
$pireps = Pirep::where('created_at', '<', $dt)
|
||||
->where(['state' => $state->value])
|
||||
->where('status', '<>', PirepStatus::PAUSED->value)
|
||||
->where('status', '<>', PirepPhase::PAUSED->value)
|
||||
->get();
|
||||
|
||||
/** @var PirepService $pirepSvc */
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Cron\Hourly;
|
||||
|
||||
use App\Contracts\Listener;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Events\CronHourly;
|
||||
use App\Events\PirepCancelled;
|
||||
use App\Models\Pirep;
|
||||
@ -25,14 +25,14 @@ class RemoveExpiredLiveFlights extends Listener
|
||||
*/
|
||||
public function handle(CronHourly $event): void
|
||||
{
|
||||
if (setting('acars.live_time') === 0) {
|
||||
if (setting('pireps.tombstone_time') === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$date = Carbon::now('UTC')->subHours(setting('acars.live_time'));
|
||||
$date = Carbon::now('UTC')->subHours(setting('pireps.tombstone_time'));
|
||||
$pireps = Pirep::where('updated_at', '<', $date)
|
||||
->where('state', PirepState::IN_PROGRESS)
|
||||
->where('status', '<>', PirepStatus::PAUSED)
|
||||
->where('status', '<>', PirepPhase::PAUSED)
|
||||
->get();
|
||||
|
||||
foreach ($pireps as $pirep) {
|
||||
|
||||
68
app/Enums/PirepPhase.php
Normal file
68
app/Enums/PirepPhase.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\HasSelect;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
enum PirepPhase: string implements HasLabel
|
||||
{
|
||||
use HasSelect;
|
||||
|
||||
case INITIATED = 'INI';
|
||||
case SCHEDULED = 'SCH';
|
||||
case BOARDING = 'BST';
|
||||
case RDY_START = 'RDT';
|
||||
case PUSHBACK_TOW = 'PBT';
|
||||
case DEPARTED = 'OFB';
|
||||
case RDY_DEICE = 'DIR';
|
||||
case STRT_DEICE = 'DIC';
|
||||
case GRND_RTRN = 'GRT';
|
||||
case TAXI = 'TXI';
|
||||
case TAKEOFF = 'TOF';
|
||||
case INIT_CLIM = 'ICL';
|
||||
case AIRBORNE = 'TKO';
|
||||
case ENROUTE = 'ENR';
|
||||
case DIVERTED = 'DV';
|
||||
case APPROACH = 'TEN';
|
||||
case APPROACH_ICAO = 'APR';
|
||||
case ON_FINAL = 'FIN';
|
||||
case LANDING = 'LDG';
|
||||
case LANDED = 'LAN';
|
||||
case ON_BLOCK = 'ONB';
|
||||
case ARRIVED = 'ARR';
|
||||
case CANCELLED = 'DX';
|
||||
case EMERG_DESCENT = 'EMG';
|
||||
case PAUSED = 'PSD';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::INITIATED => __('pireps.status.initialized'),
|
||||
self::SCHEDULED => __('pireps.status.scheduled'),
|
||||
self::BOARDING => __('pireps.status.boarding'),
|
||||
self::RDY_START => __('pireps.status.ready_start'),
|
||||
self::PUSHBACK_TOW => __('pireps.status.push_tow'),
|
||||
self::DEPARTED => __('pireps.status.departed'),
|
||||
self::RDY_DEICE => __('pireps.status.ready_deice'),
|
||||
self::STRT_DEICE => __('pireps.status.deicing'),
|
||||
self::GRND_RTRN => __('pireps.status.ground_ret'),
|
||||
self::TAXI => __('pireps.status.taxi'),
|
||||
self::TAKEOFF => __('pireps.status.takeoff'),
|
||||
self::INIT_CLIM => __('pireps.status.initial_clb'),
|
||||
self::AIRBORNE,
|
||||
self::ENROUTE => __('pireps.status.enroute'),
|
||||
self::DIVERTED => __('pireps.status.diverted'),
|
||||
self::APPROACH,
|
||||
self::APPROACH_ICAO => __('pireps.status.approach'),
|
||||
self::ON_FINAL => __('pireps.status.final_appr'),
|
||||
self::LANDING => __('pireps.status.landing'),
|
||||
self::LANDED => __('pireps.status.landed'),
|
||||
self::ON_BLOCK,
|
||||
self::ARRIVED => __('pireps.status.arrived'),
|
||||
self::CANCELLED => __('pireps.status.cancelled'),
|
||||
self::EMERG_DESCENT => __('pireps.status.emerg_decent'),
|
||||
self::PAUSED => __('pireps.status.paused'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,68 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* A class_alias, not a second enum, so PirepStatus::TAXI and PirepPhase::TAXI are
|
||||
* the same case. Composer resolves this path via PSR-4 and the alias defines the name.
|
||||
*
|
||||
* @deprecated Use \App\Enums\PirepPhase instead.
|
||||
*/
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\HasSelect;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
enum PirepStatus: string implements HasLabel
|
||||
{
|
||||
use HasSelect;
|
||||
|
||||
case INITIATED = 'INI';
|
||||
case SCHEDULED = 'SCH';
|
||||
case BOARDING = 'BST';
|
||||
case RDY_START = 'RDT';
|
||||
case PUSHBACK_TOW = 'PBT';
|
||||
case DEPARTED = 'OFB';
|
||||
case RDY_DEICE = 'DIR';
|
||||
case STRT_DEICE = 'DIC';
|
||||
case GRND_RTRN = 'GRT';
|
||||
case TAXI = 'TXI';
|
||||
case TAKEOFF = 'TOF';
|
||||
case INIT_CLIM = 'ICL';
|
||||
case AIRBORNE = 'TKO';
|
||||
case ENROUTE = 'ENR';
|
||||
case DIVERTED = 'DV';
|
||||
case APPROACH = 'TEN';
|
||||
case APPROACH_ICAO = 'APR';
|
||||
case ON_FINAL = 'FIN';
|
||||
case LANDING = 'LDG';
|
||||
case LANDED = 'LAN';
|
||||
case ON_BLOCK = 'ONB';
|
||||
case ARRIVED = 'ARR';
|
||||
case CANCELLED = 'DX';
|
||||
case EMERG_DESCENT = 'EMG';
|
||||
case PAUSED = 'PSD';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::INITIATED => __('pireps.status.initialized'),
|
||||
self::SCHEDULED => __('pireps.status.scheduled'),
|
||||
self::BOARDING => __('pireps.status.boarding'),
|
||||
self::RDY_START => __('pireps.status.ready_start'),
|
||||
self::PUSHBACK_TOW => __('pireps.status.push_tow'),
|
||||
self::DEPARTED => __('pireps.status.departed'),
|
||||
self::RDY_DEICE => __('pireps.status.ready_deice'),
|
||||
self::STRT_DEICE => __('pireps.status.deicing'),
|
||||
self::GRND_RTRN => __('pireps.status.ground_ret'),
|
||||
self::TAXI => __('pireps.status.taxi'),
|
||||
self::TAKEOFF => __('pireps.status.takeoff'),
|
||||
self::INIT_CLIM => __('pireps.status.initial_clb'),
|
||||
self::AIRBORNE,
|
||||
self::ENROUTE => __('pireps.status.enroute'),
|
||||
self::DIVERTED => __('pireps.status.diverted'),
|
||||
self::APPROACH,
|
||||
self::APPROACH_ICAO => __('pireps.status.approach'),
|
||||
self::ON_FINAL => __('pireps.status.final_appr'),
|
||||
self::LANDING => __('pireps.status.landing'),
|
||||
self::LANDED => __('pireps.status.landed'),
|
||||
self::ON_BLOCK,
|
||||
self::ARRIVED => __('pireps.status.arrived'),
|
||||
self::CANCELLED => __('pireps.status.cancelled'),
|
||||
self::EMERG_DESCENT => __('pireps.status.emerg_decent'),
|
||||
self::PAUSED => __('pireps.status.paused'),
|
||||
};
|
||||
}
|
||||
}
|
||||
class_alias(PirepPhase::class, 'App\\Enums\\PirepStatus');
|
||||
|
||||
@ -58,7 +58,7 @@ class SubfleetsRelationManager extends RelationManager
|
||||
->preloadRecordSelect()
|
||||
// recordTitle() reads the airline off every option.
|
||||
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->with('airline'))
|
||||
->recordTitle(fn (Subfleet $record): string => trim(($record->airline?->name ?? '').' - '.$record->name, ' -')),
|
||||
->recordTitle(fn (Subfleet $record): string => trim(($record->airline->name ?? '').' - '.$record->name, ' -')),
|
||||
])
|
||||
->recordActions([
|
||||
DetachAction::make(),
|
||||
|
||||
@ -54,7 +54,7 @@ class SubfleetsRelationManager extends RelationManager
|
||||
->preloadRecordSelect()
|
||||
// recordTitle() reads the airline off every option.
|
||||
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->with('airline'))
|
||||
->recordTitle(fn (Subfleet $record): string => trim(($record->airline?->name ?? '').' - '.$record->name, ' -')),
|
||||
->recordTitle(fn (Subfleet $record): string => trim(($record->airline->name ?? '').' - '.$record->name, ' -')),
|
||||
])
|
||||
->recordActions([
|
||||
DetachAction::make(),
|
||||
|
||||
@ -62,7 +62,7 @@ class SubfleetsRelationManager extends RelationManager
|
||||
->preloadRecordSelect()
|
||||
// recordTitle() reads the airline off every option.
|
||||
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->with('airline'))
|
||||
->recordTitle(fn (Subfleet $record): string => trim(($record->airline?->name ?? '').' - '.$record->name, ' -')),
|
||||
->recordTitle(fn (Subfleet $record): string => trim(($record->airline->name ?? '').' - '.$record->name, ' -')),
|
||||
])
|
||||
->recordActions([
|
||||
DetachAction::make(),
|
||||
|
||||
@ -51,7 +51,7 @@ class SubfleetsRelationManager extends RelationManager
|
||||
->preloadRecordSelect()
|
||||
// recordTitle() reads the airline off every option.
|
||||
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->with('airline'))
|
||||
->recordTitle(fn (Subfleet $record): string => trim(($record->airline?->name ?? '').' - '.$record->name, ' -')),
|
||||
->recordTitle(fn (Subfleet $record): string => trim(($record->airline->name ?? '').' - '.$record->name, ' -')),
|
||||
])
|
||||
->recordActions([
|
||||
DetachAction::make(),
|
||||
|
||||
@ -25,6 +25,7 @@ use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\EmbeddedSchema;
|
||||
use Filament\Schemas\Components\Form;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\View as ViewComponent;
|
||||
use Filament\Schemas\Components\Wizard;
|
||||
use Filament\Schemas\Components\Wizard\Step;
|
||||
use Filament\Schemas\Schema as FilamentSchema;
|
||||
@ -49,6 +50,12 @@ class Installer extends Page
|
||||
{
|
||||
protected static ?string $slug = 'install';
|
||||
|
||||
/**
|
||||
* How long the migration step waits before moving itself on. Long enough to
|
||||
* hit Pause if the log needs a read, short enough not to feel stalled.
|
||||
*/
|
||||
private const int AUTO_ADVANCE_SECONDS = 5;
|
||||
|
||||
public string $stream = 'console_output';
|
||||
|
||||
public ?array $user = null;
|
||||
@ -164,6 +171,36 @@ class Installer extends Page
|
||||
])
|
||||
->startOnStep(fn (): int => $this->computeStartStep())
|
||||
->persistStepInQueryString()
|
||||
->nextAction(fn (Action $action): Action => $action
|
||||
->label(new HtmlString(
|
||||
Blade::render(
|
||||
<<<'BLADE'
|
||||
{{ __('filament-schemas::components.wizard.actions.next_step.label') }}<span
|
||||
x-cloak
|
||||
x-show="$store.installerAutoAdvance?.active"
|
||||
x-text="` (${$store.installerAutoAdvance?.remaining})`"
|
||||
></span>
|
||||
BLADE
|
||||
)
|
||||
))
|
||||
->extraAttributes(['data-installer-next' => 'true']))
|
||||
// The wizard's only backwards step would be onto an already-run
|
||||
// migration log, so the slot carries the countdown's Pause
|
||||
// control instead of a Back button. `.stop` keeps the click from
|
||||
// reaching the footer wrapper, which would otherwise step back.
|
||||
->previousAction(fn (Action $action): Action => $action
|
||||
->label(new HtmlString(
|
||||
Blade::render(
|
||||
<<<'BLADE'
|
||||
<span x-text="$store.installerAutoAdvance?.paused ? @js(__('installer.resume')) : @js(__('installer.pause'))">{{ __('installer.pause') }}</span>
|
||||
BLADE
|
||||
)
|
||||
))
|
||||
->extraAttributes([
|
||||
'x-cloak' => true,
|
||||
'x-show' => '$store.installerAutoAdvance?.active',
|
||||
'x-on:click.stop' => '$store.installerAutoAdvance.toggle()',
|
||||
]))
|
||||
->submitAction(
|
||||
new HtmlString(
|
||||
Blade::render(
|
||||
@ -298,6 +335,16 @@ class Installer extends Page
|
||||
to: $this->stream
|
||||
);
|
||||
|
||||
// Only hand the step over to the countdown once there is genuinely
|
||||
// nothing left to run — otherwise it would advance into the step's own
|
||||
// validation, which halts on pending migrations.
|
||||
if (count(app(MigrationService::class)->migrationsAvailable()) === 0) {
|
||||
$this->dispatch(
|
||||
'installer-migrations-complete',
|
||||
seconds: self::AUTO_ADVANCE_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
return $this->migrationOutput = $output;
|
||||
}
|
||||
|
||||
@ -500,6 +547,11 @@ class Installer extends Page
|
||||
->viewData([
|
||||
'stream' => $this->stream,
|
||||
]),
|
||||
|
||||
ViewComponent::make('filament.installer.auto-advance')
|
||||
->viewData([
|
||||
'seconds' => self::AUTO_ADVANCE_SECONDS,
|
||||
]),
|
||||
])
|
||||
->afterValidation(function (): void {
|
||||
if (count(app(MigrationService::class)->migrationsAvailable()) > 0) {
|
||||
|
||||
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Contracts\Controller;
|
||||
use App\Enums\AcarsType;
|
||||
use App\Enums\PirepState;
|
||||
use App\Events\AcarsUpdate;
|
||||
use App\Exceptions\PirepCancelled;
|
||||
use App\Exceptions\PirepNotFound;
|
||||
@ -14,6 +15,7 @@ use App\Http\Resources\AcarsRouteResource;
|
||||
use App\Http\Resources\PirepResource;
|
||||
use App\Models\Acars;
|
||||
use App\Models\Pirep;
|
||||
use App\Models\PirepPosition;
|
||||
use App\Services\GeoService;
|
||||
use Carbon\Carbon;
|
||||
use DateTime;
|
||||
@ -64,9 +66,8 @@ class AcarsController extends Controller
|
||||
*/
|
||||
public function live_flights()
|
||||
{
|
||||
$pireps = Pirep::activeFlights(setting('acars.live_time'))->get()->filter(
|
||||
fn (Pirep $pirep): bool => $pirep->position !== null
|
||||
);
|
||||
// No filtering: the join is the whole membership test.
|
||||
$pireps = Pirep::onLiveMap()->get();
|
||||
|
||||
return PirepResource::collection($pireps);
|
||||
}
|
||||
@ -76,7 +77,7 @@ class AcarsController extends Controller
|
||||
*/
|
||||
public function pireps_geojson(Request $request): JsonResponse
|
||||
{
|
||||
$pireps = Pirep::activeFlights(setting('acars.live_time'))->get();
|
||||
$pireps = Pirep::onLiveMap()->get();
|
||||
$positions = $this->geoSvc->getFeatureForLiveFlights($pireps);
|
||||
|
||||
return response()->json([
|
||||
@ -187,18 +188,70 @@ class AcarsController extends Controller
|
||||
}
|
||||
|
||||
// Change the PIREP status if it's as SCHEDULED before
|
||||
/*if ($pirep->status === PirepStatus::INITIATED) {
|
||||
$pirep->status = PirepStatus::AIRBORNE;
|
||||
/*if ($pirep->status === PirepPhase::INITIATED) {
|
||||
$pirep->status = PirepPhase::AIRBORNE;
|
||||
}*/
|
||||
|
||||
$pirep->save();
|
||||
|
||||
// Post a new update for this ACARS position
|
||||
event(new AcarsUpdate($pirep, $pirep->position));
|
||||
$latest = $this->syncPosition($pirep);
|
||||
|
||||
// Still the acars row, not the position row - this event's payload is unchanged.
|
||||
event(new AcarsUpdate($pirep, $latest));
|
||||
|
||||
return $this->message($count.' positions added', $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the position row from the PIREP's newest breadcrumb. Resolved from
|
||||
* `acars` server-side, not from the batch, so a replayed or out-of-order batch
|
||||
* can't move the aircraft backwards - `acars`.`created_at` is collection time.
|
||||
*
|
||||
* Only IN_PROGRESS and PENDING get a row. PENDING because filing happens while a
|
||||
* client may still be posting the tail of the flight. Refused batches still write
|
||||
* their `acars` rows.
|
||||
*/
|
||||
private function syncPosition(Pirep $pirep): ?Acars
|
||||
{
|
||||
/** @var ?Acars $latest */
|
||||
$latest = Acars::query()
|
||||
->forPirep($pirep->id)
|
||||
->flightPath()
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('order', 'desc')
|
||||
->orderBy('sim_time', 'desc')
|
||||
->first();
|
||||
|
||||
if ($latest === null
|
||||
|| !in_array($pirep->state, [PirepState::IN_PROGRESS, PirepState::PENDING], true)
|
||||
) {
|
||||
return $latest;
|
||||
}
|
||||
|
||||
PirepPosition::updateOrCreate(
|
||||
['pirep_id' => $pirep->id],
|
||||
[
|
||||
'user_id' => $pirep->user_id,
|
||||
// Off the PIREP, not `acars`.`status` - a different column entirely.
|
||||
'phase' => $pirep->status,
|
||||
'lat' => $latest->lat ?? 0,
|
||||
'lon' => $latest->lon ?? 0,
|
||||
'heading' => $latest->heading ?? 0,
|
||||
'distance' => $latest->distance?->internal(2) ?? 0,
|
||||
'altitude_agl' => $latest->altitude_agl ?? 0,
|
||||
'altitude_msl' => $latest->altitude_msl ?? 0,
|
||||
'vs' => $latest->vs ?? 0,
|
||||
'gs' => $latest->gs ?? 0,
|
||||
'ias' => $latest->ias ?? 0,
|
||||
// On the PIREP: `acars` has neither, and its `fuel` is fuel remaining.
|
||||
'flight_time' => $pirep->flight_time ?? 0,
|
||||
'fuel_used' => $pirep->fuel_used?->internal(2) ?? 0,
|
||||
]
|
||||
);
|
||||
|
||||
return $latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post ACARS LOG update for a PIREP. These updates won't show up on the map
|
||||
* But rather in a log file.
|
||||
|
||||
@ -21,7 +21,7 @@ use Override;
|
||||
* search Free-text or `field:value[;field:value...]` (max 255 chars)
|
||||
* user_id Filter by pilot
|
||||
* state PirepState integer
|
||||
* status PirepStatus string
|
||||
* status PirepPhase string
|
||||
* orderBy One of ORDERABLE_FIELDS
|
||||
* sortedBy asc | desc
|
||||
* page Pagination page number (min 1)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\UserState;
|
||||
use App\Events\AwardAwarded;
|
||||
use App\Events\NewsAdded;
|
||||
@ -161,7 +161,7 @@ class NotificationsSubscriber
|
||||
* Reduced the messages (Boarding, Pushback, TakeOff, Landing and non-normals only)
|
||||
* If needed array can be tied to a setting at admin side for further customization
|
||||
*
|
||||
* PirepStatus::DIVERTED is deliberately absent from the list: a diversion is
|
||||
* PirepPhase::DIVERTED is deliberately absent from the list: a diversion is
|
||||
* announced by PirepService::handleDiversion() through Broadcast\PirepDiverted,
|
||||
* which carries the diversion airport and reason. Listing it here as well
|
||||
* announced every diversion twice.
|
||||
@ -171,14 +171,14 @@ class NotificationsSubscriber
|
||||
Log::info('NotificationEvents::onPirepStatusChange: '.$event->pirep->id.' status changed');
|
||||
|
||||
$message_types = [
|
||||
PirepStatus::BOARDING,
|
||||
PirepStatus::PUSHBACK_TOW,
|
||||
PirepStatus::GRND_RTRN,
|
||||
PirepStatus::TAKEOFF,
|
||||
PirepStatus::LANDED,
|
||||
PirepStatus::CANCELLED,
|
||||
PirepStatus::PAUSED,
|
||||
PirepStatus::EMERG_DESCENT,
|
||||
PirepPhase::BOARDING,
|
||||
PirepPhase::PUSHBACK_TOW,
|
||||
PirepPhase::GRND_RTRN,
|
||||
PirepPhase::TAKEOFF,
|
||||
PirepPhase::LANDED,
|
||||
PirepPhase::CANCELLED,
|
||||
PirepPhase::PAUSED,
|
||||
PirepPhase::EMERG_DESCENT,
|
||||
];
|
||||
|
||||
if (setting('notifications.discord_pirep_status', true) && in_array($event->pirep->status, $message_types,
|
||||
|
||||
@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Kyslik\ColumnSortable\Sortable;
|
||||
use LogicException;
|
||||
use Override;
|
||||
@ -418,7 +419,7 @@ class Flight extends Model
|
||||
* the legacy "leg zero or empty" sentinel) from `'0'` (string), both of
|
||||
* which we collapse, from a real string `'abc'` that we preserve.
|
||||
*
|
||||
* Backed enums (e.g. `PirepStatus::DIVERTED` set on `route_code` by the
|
||||
* Backed enums (e.g. `PirepPhase::DIVERTED` set on `route_code` by the
|
||||
* diversion handler) are coerced via their backing `->value`. Pure unit
|
||||
* enums fall back to their `->name`. This keeps legacy callers working
|
||||
* without forcing them to pre-stringify enum values.
|
||||
@ -811,7 +812,9 @@ class Flight extends Model
|
||||
* bundle, ordered by id so the same N come back every request. The inherited
|
||||
* set here can therefore be a subset of what `accessibleSubfleetsFor`
|
||||
* returns for the same flight. A list row is a summary; the flight page is
|
||||
* the authority.
|
||||
* the authority. A bundle the cap actually trims logs one debug line per
|
||||
* request — the trimmed rows are the highest ids, so what disappears is
|
||||
* whatever an admin most recently added.
|
||||
*
|
||||
* Which rung applies is decided by configuration BEFORE access filtering.
|
||||
* The has-pins probe deliberately skips `allowedFor`, so a flight whose pins
|
||||
@ -843,7 +846,7 @@ class Flight extends Model
|
||||
// release has no key at all and `PHPVMS_INHERITED_SUBFLEET_LIMIT=` in
|
||||
// .env reads back as '' — both cast to 0, as does a literal 0. Hence
|
||||
// the named default, and the floor for a negative.
|
||||
$inheritedLimit = max(1, (int) config('phpvms.subfleets.inherited_list_limit', 5) ?: 5);
|
||||
$inheritedLimit = max(1, (int) config('phpvms.subfleets.inherited_list_limit', 25) ?: 25);
|
||||
|
||||
// Whether the caller already asked for the bundle. The eager load
|
||||
// added below is this scope's own working state and gets dropped
|
||||
@ -861,14 +864,28 @@ class Flight extends Model
|
||||
// accessibleSubfleetsFor. Without withTrashed a soft-deleted
|
||||
// bundle would resolve to null here and quietly strip its
|
||||
// flights of the subfleets they are still configured with.
|
||||
'bundle' => fn ($bq) => $bq->withTrashed(),
|
||||
//
|
||||
// The count is a correlated subselect on the same bundle query
|
||||
// — no extra round trip — and is the only way to tell a bundle
|
||||
// that fits under the cap from one the cap silently trimmed,
|
||||
// since the capped load below can only ever hand back
|
||||
// `$inheritedLimit` rows. It carries the same `allowedFor`
|
||||
// constraint, so it counts what the pilot could have seen, not
|
||||
// what the bundle holds.
|
||||
'bundle' => fn ($bq) => $bq->withTrashed()
|
||||
->withCount(['subfleets' => fn ($cq) => $cq->allowedFor($user)]),
|
||||
'bundle.subfleets' => fn ($sq) => $sq->allowedFor($user)
|
||||
->with($nested)
|
||||
->orderBy('subfleets.id')
|
||||
->limit($inheritedLimit),
|
||||
'subfleets' => fn ($sq) => $sq->allowedFor($user)->with($nested),
|
||||
])
|
||||
->afterQuery(function ($results) use ($callerLoadsBundle) {
|
||||
->afterQuery(function ($results) use ($callerLoadsBundle, $inheritedLimit) {
|
||||
// One hydrated Bundle backs every flight pointing at it, so a
|
||||
// page of 100 flights on one over-capped bundle has to report
|
||||
// that bundle once, not 100 times.
|
||||
$seenBundles = [];
|
||||
|
||||
foreach ($results as $flight) {
|
||||
// afterQuery also fires for pluck(), whose collection holds
|
||||
// scalars rather than models. The probe check keeps this
|
||||
@ -900,6 +917,30 @@ class Flight extends Model
|
||||
$flight->unsetRelation('bundle');
|
||||
}
|
||||
|
||||
// The count rides the same bundle row and is this scope's
|
||||
// own working state, so it is stripped for the same reason
|
||||
// the probe is: a bundle the caller eager-loaded would
|
||||
// otherwise serialise it. Stripping it also makes a second
|
||||
// application of this scope read 0 and stay quiet, so the
|
||||
// once-per-bundle guarantee survives a doubled scope.
|
||||
if ($bundle !== null && !array_key_exists($bundle->getKey(), $seenBundles)) {
|
||||
$seenBundles[$bundle->getKey()] = true;
|
||||
|
||||
$accessible = (int) $bundle->getAttribute('subfleets_count');
|
||||
unset($bundle['subfleets_count']);
|
||||
|
||||
// The trimmed rows are the highest ids — the subfleet
|
||||
// an admin just added and is now hunting for. Silence
|
||||
// makes that look like a permissions bug.
|
||||
if ($accessible > $inheritedLimit) {
|
||||
Log::debug(
|
||||
'Flight list: bundle '.$bundle->getKey().' has '.$accessible
|
||||
.' accessible subfleets, showing the first '.$inheritedLimit
|
||||
.' (phpvms.subfleets.inherited_list_limit)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$inherits) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -9,9 +9,9 @@ use App\Contracts\Model;
|
||||
use App\Enums\AcarsType;
|
||||
use App\Enums\FlightType;
|
||||
use App\Enums\PirepFieldSource;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepSource;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Enums\SimType;
|
||||
use App\Events\PirepStateChange;
|
||||
use App\Events\PirepStatusChange;
|
||||
@ -66,7 +66,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
|
||||
* @property PirepSource|null $source
|
||||
* @property string|null $source_name
|
||||
* @property PirepState $state
|
||||
* @property PirepStatus $status
|
||||
* @property PirepPhase $status
|
||||
* @property mixed|null $submitted_at
|
||||
* @property mixed|null $block_off_time
|
||||
* @property mixed|null $block_on_time
|
||||
@ -99,7 +99,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
|
||||
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
|
||||
* @property-read int|null $notifications_count
|
||||
* @property-read User|null $pilot
|
||||
* @property-read Acars|null $position
|
||||
* @property-read PirepPosition|null $position
|
||||
* @property-read float $progress_percent
|
||||
* @property-read bool $read_only
|
||||
* @property-read SimBrief|null $simbrief
|
||||
@ -107,8 +107,8 @@ use Spatie\Activitylog\Traits\LogsActivity;
|
||||
* @property-read int|null $transactions_count
|
||||
* @property-read User|null $user
|
||||
*
|
||||
* @method static Builder<static>|Pirep activeFlights(int $liveTime = 0)
|
||||
* @method static PirepFactory factory($count = null, $state = [])
|
||||
* @method static Builder<static>|Pirep onLiveMap()
|
||||
* @method static Builder<static>|Pirep newModelQuery()
|
||||
* @method static Builder<static>|Pirep newQuery()
|
||||
* @method static Builder<static>|Pirep onlyTrashed()
|
||||
@ -550,15 +550,12 @@ class Pirep extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship that holds the current position, but limits the ACARS
|
||||
* relationship to only one row (the latest), to prevent an N+! problem
|
||||
* The flight's current position. Was a latest-of-many over `acars`; now a plain
|
||||
* one-to-one on `pirep_positions`.
|
||||
*/
|
||||
public function position(): HasOne
|
||||
{
|
||||
return $this->hasOne(Acars::class, 'pirep_id')
|
||||
->flightPath()
|
||||
->latest('created_at')
|
||||
->latest('sim_time');
|
||||
return $this->hasOne(PirepPosition::class, 'pirep_id');
|
||||
}
|
||||
|
||||
public function simbrief(): BelongsTo
|
||||
@ -604,28 +601,22 @@ class Pirep extends Model
|
||||
'source' => PirepSource::class,
|
||||
'sim_type' => SimType::class,
|
||||
'state' => PirepState::class,
|
||||
'status' => PirepStatus::class,
|
||||
'status' => PirepPhase::class,
|
||||
'submitted_at' => CarbonCast::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: PIREPs with state = IN_PROGRESS, optionally constrained to those
|
||||
* updated within the last $liveTime hours, ordered by updated_at desc, with
|
||||
* the relations needed by the live-map / live-flights endpoints eager-loaded.
|
||||
*
|
||||
* Replaces the previously-misnamed AcarsRepository::getPositions() method.
|
||||
* Scope: the flights on the live map. Membership is the join and nothing else -
|
||||
* eviction belongs to PirepPositionExpiration, not to a query that runs per poll.
|
||||
*/
|
||||
public function scopeActiveFlights(Builder $query, int $liveTime = 0): Builder
|
||||
public function scopeOnLiveMap(Builder $query): Builder
|
||||
{
|
||||
$query
|
||||
->with(['aircraft', 'airline', 'arr_airport', 'dpt_airport', 'position', 'user'])
|
||||
->where('state', PirepState::IN_PROGRESS);
|
||||
|
||||
if ($liveTime > 0) {
|
||||
$query->where('updated_at', '>=', Carbon::now()->subHours($liveTime));
|
||||
}
|
||||
|
||||
return $query->orderBy('updated_at', 'desc');
|
||||
return $query
|
||||
// user.airline because User::ident reads it.
|
||||
->with(['aircraft', 'airline', 'arr_airport', 'dpt_airport', 'position', 'user', 'user.airline'])
|
||||
->join('pirep_positions', 'pirep_positions.pirep_id', '=', 'pireps.id')
|
||||
->select('pireps.*')
|
||||
->orderBy('pirep_positions.updated_at', 'desc');
|
||||
}
|
||||
}
|
||||
|
||||
115
app/Models/PirepPosition.php
Normal file
115
app/Models/PirepPosition.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\DistanceCast;
|
||||
use App\Casts\FuelCast;
|
||||
use App\Contracts\Model;
|
||||
use App\Enums\PirepPhase;
|
||||
use Database\Factories\PirepPositionFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\WithoutIncrementing;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* A PIREP's last-known position, one row per flight. Its existence is what puts a
|
||||
* flight on the live map. `updated_at` moves on position batches only.
|
||||
*
|
||||
* @property string $pirep_id
|
||||
* @property int $user_id
|
||||
* @property PirepPhase $phase
|
||||
* @property float $lat
|
||||
* @property float $lon
|
||||
* @property int $heading
|
||||
* @property mixed $distance
|
||||
* @property float $altitude_agl
|
||||
* @property float $altitude_msl
|
||||
* @property float $vs
|
||||
* @property int $gs
|
||||
* @property int $ias
|
||||
* @property int $flight_time
|
||||
* @property mixed $fuel_used
|
||||
* @property-read float $altitude
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Pirep|null $pirep
|
||||
* @property-read User|null $user
|
||||
*
|
||||
* @method static PirepPositionFactory factory($count = null, $state = [])
|
||||
* @method static Builder<static>|PirepPosition newModelQuery()
|
||||
* @method static Builder<static>|PirepPosition newQuery()
|
||||
* @method static Builder<static>|PirepPosition query()
|
||||
*/
|
||||
#[WithoutIncrementing]
|
||||
class PirepPosition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $table = 'pirep_positions';
|
||||
|
||||
protected $appends = ['altitude'];
|
||||
|
||||
protected $primaryKey = 'pirep_id';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $fillable = [
|
||||
'pirep_id',
|
||||
'user_id',
|
||||
'phase',
|
||||
'lat',
|
||||
'lon',
|
||||
'heading',
|
||||
'distance',
|
||||
'altitude_agl',
|
||||
'altitude_msl',
|
||||
'vs',
|
||||
'gs',
|
||||
'ias',
|
||||
'flight_time',
|
||||
'fuel_used',
|
||||
];
|
||||
|
||||
/** Units come from the casts, matching `acars` and `pireps`. */
|
||||
#[Override]
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
'phase' => PirepPhase::class,
|
||||
'lat' => 'float',
|
||||
'lon' => 'float',
|
||||
'heading' => 'integer',
|
||||
'distance' => DistanceCast::class,
|
||||
'altitude_agl' => 'float',
|
||||
'altitude_msl' => 'float',
|
||||
'vs' => 'float',
|
||||
'gs' => 'integer',
|
||||
'ias' => 'integer',
|
||||
'flight_time' => 'integer',
|
||||
'fuel_used' => FuelCast::class,
|
||||
];
|
||||
}
|
||||
|
||||
/** Mirrors Acars::altitude, which the live map GeoJSON reads. */
|
||||
protected function altitude(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $_, array $attrs): float => (float) $attrs['altitude_msl'],
|
||||
);
|
||||
}
|
||||
|
||||
public function pirep(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Pirep::class, 'pirep_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Notifications\Messages\Broadcast;
|
||||
|
||||
use App\Contracts\Notification;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Models\Pirep;
|
||||
use App\Notifications\Concerns\BuildsDiscordEmbeds;
|
||||
use App\Notifications\DiscordEmbedColor;
|
||||
@ -24,11 +24,11 @@ class PirepStatusChanged extends Notification implements ShouldQueue
|
||||
* Statuses that read as trouble rather than routine progress.
|
||||
*/
|
||||
private const array DANGER_STATUSES = [
|
||||
PirepStatus::GRND_RTRN,
|
||||
PirepStatus::DIVERTED,
|
||||
PirepStatus::CANCELLED,
|
||||
PirepStatus::PAUSED,
|
||||
PirepStatus::EMERG_DESCENT,
|
||||
PirepPhase::GRND_RTRN,
|
||||
PirepPhase::DIVERTED,
|
||||
PirepPhase::CANCELLED,
|
||||
PirepPhase::PAUSED,
|
||||
PirepPhase::EMERG_DESCENT,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -6,9 +6,9 @@ use App\Auth\InstallSafeUserProvider;
|
||||
use App\Contracts\Metar;
|
||||
use App\Contracts\Model as BaseModel;
|
||||
use App\Enums\ActiveState;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepSource;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Enums\UserState;
|
||||
use App\Http\Composers\PageLinksComposer;
|
||||
use App\Http\Composers\VersionComposer;
|
||||
@ -302,7 +302,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
'UserState' => UserState::class,
|
||||
'PirepSource' => PirepSource::class,
|
||||
'PirepState' => PirepState::class,
|
||||
'PirepStatus' => PirepStatus::class,
|
||||
'PirepPhase' => PirepPhase::class,
|
||||
'PirepStatus' => PirepPhase::class,
|
||||
];
|
||||
|
||||
foreach ($aliases as $alias => $class) {
|
||||
|
||||
@ -7,9 +7,9 @@ namespace App\Services\Finance;
|
||||
use App\Contracts\Service;
|
||||
use App\Enums\ExpenseType;
|
||||
use App\Enums\FuelType;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepSource;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Events\Expenses as ExpensesEvent;
|
||||
use App\Events\Fares as FaresEvent;
|
||||
use App\Models\Aircraft;
|
||||
@ -209,7 +209,7 @@ class PirepFinanceService extends Service
|
||||
$prev_flight = Pirep::where([
|
||||
'aircraft_id' => $pirep->aircraft->id,
|
||||
'state' => PirepState::ACCEPTED,
|
||||
'status' => PirepStatus::ARRIVED,
|
||||
'status' => PirepPhase::ARRIVED,
|
||||
])
|
||||
->where('submitted_at', '<=', $pirep->submitted_at)
|
||||
->orderby('submitted_at', 'desc')
|
||||
|
||||
@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Contracts\Service;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Exceptions\DuplicateFlight;
|
||||
use App\Models\Bid;
|
||||
use App\Models\Flight;
|
||||
@ -269,14 +269,14 @@ class FlightService extends Service
|
||||
public function removeExpiredRepositionFlights(): void
|
||||
{
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, Flight> $flights */
|
||||
$flights = Flight::where('route_code', PirepStatus::DIVERTED)->get();
|
||||
$flights = Flight::where('route_code', PirepPhase::DIVERTED)->get();
|
||||
|
||||
foreach ($flights as $flight) {
|
||||
$diverted_pirep = Pirep::with('aircraft')
|
||||
->where([
|
||||
'user_id' => $flight->user_id,
|
||||
'arr_airport_id' => $flight->dpt_airport_id,
|
||||
'status' => PirepStatus::DIVERTED,
|
||||
'status' => PirepPhase::DIVERTED,
|
||||
'state' => PirepState::ACCEPTED,
|
||||
])
|
||||
->orderBy('submitted_at', 'desc')
|
||||
|
||||
@ -224,6 +224,23 @@ class GeoService extends Service
|
||||
]);
|
||||
}
|
||||
|
||||
// Breadcrumbs and the live position move at different cadences, so the
|
||||
// trail can end minutes behind the marker. Appending closes that gap - a
|
||||
// real reported point, not an interpolated one.
|
||||
$live = $pirep->position;
|
||||
$last = $actual_route->last();
|
||||
|
||||
if ($live !== null
|
||||
&& ($last === null
|
||||
|| (float) $last->lat !== (float) $live->lat
|
||||
|| (float) $last->lon !== (float) $live->lon)
|
||||
) {
|
||||
$route->addPoint($live->lat, $live->lon, [
|
||||
'pirep_id' => $pirep->id,
|
||||
'alt' => $live->altitude,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
// If there is a position update from ACARS, show where it is
|
||||
// Otherwise, just assume it's at the arrival airport currently
|
||||
|
||||
@ -23,6 +23,7 @@ use App\Models\Subfleet;
|
||||
use App\Models\User;
|
||||
use App\Models\UserAward;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Override;
|
||||
|
||||
class ClearDatabase extends BaseImporter
|
||||
@ -56,42 +57,70 @@ class ClearDatabase extends BaseImporter
|
||||
{
|
||||
$this->info('Running database cleanup/empty before starting');
|
||||
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||
// MySQL refuses to TRUNCATE any table a foreign key points at, even
|
||||
// when the referencing table holds no rows, so the constraints are
|
||||
// suspended and children are cleared before parents.
|
||||
// Schema::withoutForeignKeyConstraints() rather than the bare
|
||||
// `SET FOREIGN_KEY_CHECKS` this used to issue: that statement is a
|
||||
// syntax error on the SQLite and Postgres targets the importer also
|
||||
// writes to, and it left the checks off for the rest of the
|
||||
// connection when a truncate in between threw.
|
||||
Schema::withoutForeignKeyConstraints(function (): void {
|
||||
Bid::truncate();
|
||||
File::truncate();
|
||||
News::truncate();
|
||||
|
||||
Bid::truncate();
|
||||
File::truncate();
|
||||
News::truncate();
|
||||
Expense::truncate();
|
||||
JournalTransaction::truncate();
|
||||
Journal::truncate();
|
||||
Ledger::truncate();
|
||||
|
||||
Expense::truncate();
|
||||
JournalTransaction::truncate();
|
||||
Journal::truncate();
|
||||
Ledger::truncate();
|
||||
// Clear flights
|
||||
DB::table('flight_fare')->truncate();
|
||||
DB::table('flight_subfleet')->truncate();
|
||||
FlightField::truncate();
|
||||
FlightFieldValue::truncate();
|
||||
Flight::truncate();
|
||||
|
||||
// Clear flights
|
||||
DB::table('flight_fare')->truncate();
|
||||
DB::table('flight_subfleet')->truncate();
|
||||
FlightField::truncate();
|
||||
FlightFieldValue::truncate();
|
||||
Flight::truncate();
|
||||
Subfleet::truncate();
|
||||
Aircraft::truncate();
|
||||
// Every one of these is reachable only through a subfleet, and
|
||||
// `subfleets.id` is an auto-increment this truncate resets: a row
|
||||
// left behind does not dangle, it renames itself onto whichever
|
||||
// freshly imported subfleet lands on its old id. A bundle's
|
||||
// subfleet defaults cannot outlive the subfleets they name, and
|
||||
// neither can a fare override, a rank grant or a type rating --
|
||||
// ImportService::importSubfleets() clears the same set.
|
||||
DB::table('bundle_subfleet')->truncate();
|
||||
DB::table('subfleet_fare')->truncate();
|
||||
DB::table('subfleet_rank')->truncate();
|
||||
DB::table('typerating_subfleet')->truncate();
|
||||
Subfleet::truncate();
|
||||
|
||||
Airline::truncate();
|
||||
Airport::truncate();
|
||||
Acars::truncate();
|
||||
Pirep::truncate();
|
||||
Aircraft::truncate();
|
||||
|
||||
UserAward::truncate();
|
||||
User::truncate();
|
||||
Airline::truncate();
|
||||
Airport::truncate();
|
||||
Acars::truncate();
|
||||
Pirep::truncate();
|
||||
|
||||
// Clear permissions
|
||||
DB::table('permission_role')->truncate();
|
||||
DB::table('permission_user')->truncate();
|
||||
DB::table('role_user')->truncate();
|
||||
UserAward::truncate();
|
||||
User::truncate();
|
||||
|
||||
// Role::truncate();
|
||||
// Clear permissions. These are the spatie/laravel-permission
|
||||
// pivots: the laratrust tables this used to name
|
||||
// (`permission_role`, `permission_user`, `role_user`) were dropped
|
||||
// by the v7 -> v8 migration, so every run of this importer died
|
||||
// here on "no such table". Both are keyed by `users.id`, another
|
||||
// auto-increment reset above -- a row left behind would grant a
|
||||
// freshly imported pilot the roles of the one who held that id.
|
||||
//
|
||||
// `role_has_permissions` is deliberately absent: roles and
|
||||
// permissions are seeded configuration, not imported data, and
|
||||
// neither table is truncated here.
|
||||
DB::table('model_has_roles')->truncate();
|
||||
DB::table('model_has_permissions')->truncate();
|
||||
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||
// Role::truncate();
|
||||
});
|
||||
|
||||
$this->idMapper->clear();
|
||||
}
|
||||
|
||||
@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Services\LegacyImporter;
|
||||
|
||||
use App\Enums\FlightType;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepSource;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Models\Pirep;
|
||||
use App\Services\FinanceService;
|
||||
use App\Support\Money;
|
||||
@ -75,7 +75,7 @@ class PirepImporter extends BaseImporter
|
||||
'route' => $row->route ?: '',
|
||||
'source_name' => $row->source,
|
||||
'state' => $this->mapState($row->accepted),
|
||||
'status' => PirepStatus::ARRIVED,
|
||||
'status' => PirepPhase::ARRIVED,
|
||||
'submitted_at' => $this->parseDate($row->submitdate),
|
||||
'created_at' => $this->parseDate($row->submitdate),
|
||||
'updated_at' => $this->parseDate($row->submitdate),
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Services\Pirep;
|
||||
|
||||
use App\Enums\AcarsType;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Models\Acars;
|
||||
use App\Models\Pirep;
|
||||
use DateTimeInterface;
|
||||
@ -61,7 +61,7 @@ class PerformanceChartService
|
||||
|
||||
/**
|
||||
* Compact phase-timing summary rendered as four stat boxes beneath the
|
||||
* performance chart. Bucket per PirepStatus code:
|
||||
* performance chart. Bucket per PirepPhase code:
|
||||
*
|
||||
* - climb : TAKEOFF, INIT_CLIM, AIRBORNE
|
||||
* - cruise : ENROUTE
|
||||
@ -402,24 +402,24 @@ class PerformanceChartService
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-substring → PirepStatus marker table. Ordered by typical flight
|
||||
* Log-substring → PirepPhase marker table. Ordered by typical flight
|
||||
* sequence; substrings matched case-insensitively against the first
|
||||
* occurrence of each row in the LOG stream (with "flaps set to up"
|
||||
* gated to fire only after takeoff — pre-takeoff flap retract and
|
||||
* post-landing flap stow share the same string).
|
||||
*
|
||||
* @var array<int, array{needle: string, status: PirepStatus, after_takeoff: bool}>
|
||||
* @var array<int, array{needle: string, status: PirepPhase, after_takeoff: bool}>
|
||||
*/
|
||||
private const array LOG_MARKERS = [
|
||||
['needle' => 'started boarding', 'status' => PirepStatus::BOARDING, 'after_takeoff' => false],
|
||||
['needle' => 'started pushback', 'status' => PirepStatus::PUSHBACK_TOW, 'after_takeoff' => false],
|
||||
['needle' => 'started taxi out', 'status' => PirepStatus::TAXI, 'after_takeoff' => false],
|
||||
['needle' => 'started takeoff', 'status' => PirepStatus::TAKEOFF, 'after_takeoff' => false],
|
||||
['needle' => 'flaps set to up', 'status' => PirepStatus::ENROUTE, 'after_takeoff' => true],
|
||||
['needle' => 'on approach', 'status' => PirepStatus::APPROACH_ICAO, 'after_takeoff' => true],
|
||||
['needle' => 'on final approach', 'status' => PirepStatus::ON_FINAL, 'after_takeoff' => true],
|
||||
['needle' => 'landing rate', 'status' => PirepStatus::LANDING, 'after_takeoff' => true],
|
||||
['needle' => 'blocks on time', 'status' => PirepStatus::ON_BLOCK, 'after_takeoff' => true],
|
||||
['needle' => 'started boarding', 'status' => PirepPhase::BOARDING, 'after_takeoff' => false],
|
||||
['needle' => 'started pushback', 'status' => PirepPhase::PUSHBACK_TOW, 'after_takeoff' => false],
|
||||
['needle' => 'started taxi out', 'status' => PirepPhase::TAXI, 'after_takeoff' => false],
|
||||
['needle' => 'started takeoff', 'status' => PirepPhase::TAKEOFF, 'after_takeoff' => false],
|
||||
['needle' => 'flaps set to up', 'status' => PirepPhase::ENROUTE, 'after_takeoff' => true],
|
||||
['needle' => 'on approach', 'status' => PirepPhase::APPROACH_ICAO, 'after_takeoff' => true],
|
||||
['needle' => 'on final approach', 'status' => PirepPhase::ON_FINAL, 'after_takeoff' => true],
|
||||
['needle' => 'landing rate', 'status' => PirepPhase::LANDING, 'after_takeoff' => true],
|
||||
['needle' => 'blocks on time', 'status' => PirepPhase::ON_BLOCK, 'after_takeoff' => true],
|
||||
];
|
||||
|
||||
/**
|
||||
@ -502,7 +502,7 @@ class PerformanceChartService
|
||||
|
||||
$matched[$i] = ['status' => $marker['status'], 'ts' => $ts];
|
||||
|
||||
if ($marker['status'] === PirepStatus::TAKEOFF) {
|
||||
if ($marker['status'] === PirepPhase::TAKEOFF) {
|
||||
$takeoffSeen = true;
|
||||
}
|
||||
|
||||
@ -547,9 +547,9 @@ class PerformanceChartService
|
||||
return $this->collapseToPhases(
|
||||
$samples,
|
||||
fn ($s): string => match (true) {
|
||||
(float) ($s->vs ?? 0) > 200 => PirepStatus::INIT_CLIM->value,
|
||||
(float) ($s->vs ?? 0) < -200 => PirepStatus::APPROACH_ICAO->value,
|
||||
default => PirepStatus::ENROUTE->value,
|
||||
(float) ($s->vs ?? 0) > 200 => PirepPhase::INIT_CLIM->value,
|
||||
(float) ($s->vs ?? 0) < -200 => PirepPhase::APPROACH_ICAO->value,
|
||||
default => PirepPhase::ENROUTE->value,
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -557,7 +557,7 @@ class PerformanceChartService
|
||||
/**
|
||||
* Walk the sample collection, group contiguous runs that share the same
|
||||
* phase code (resolved by `$codeFor`), and emit one entry per run with
|
||||
* its translated PirepStatus label.
|
||||
* its translated PirepPhase label.
|
||||
*
|
||||
* @param callable(Acars): string $codeFor
|
||||
* @return array<int, array{code: string, label: string, start: int, end: int}>
|
||||
@ -595,7 +595,7 @@ class PerformanceChartService
|
||||
}
|
||||
|
||||
/**
|
||||
* Label = the PirepStatus 3-letter code itself (e.g. 'TXI', 'ENR').
|
||||
* Label = the PirepPhase 3-letter code itself (e.g. 'TXI', 'ENR').
|
||||
* Chart corner real estate is cramped and the codes are unambiguous
|
||||
* to anyone reading flight data. Unknown codes pass through as-is.
|
||||
*/
|
||||
|
||||
@ -8,9 +8,9 @@ use App\Contracts\Service;
|
||||
use App\Enums\AcarsType;
|
||||
use App\Enums\AircraftState;
|
||||
use App\Enums\FlightType;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepSource;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Events\PirepAccepted;
|
||||
use App\Events\PirepCancelled;
|
||||
use App\Events\PirepFiled;
|
||||
@ -35,6 +35,7 @@ use App\Models\Pirep;
|
||||
use App\Models\PirepComment;
|
||||
use App\Models\PirepFare;
|
||||
use App\Models\PirepFieldValue;
|
||||
use App\Models\PirepPosition;
|
||||
use App\Models\SimBrief;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Messages\Broadcast\PirepDiverted;
|
||||
@ -74,7 +75,7 @@ class PirepService extends Service
|
||||
$attrs['state'] = PirepState::IN_PROGRESS;
|
||||
|
||||
if (!array_key_exists('status', $attrs)) {
|
||||
$attrs['status'] = PirepStatus::INITIATED;
|
||||
$attrs['status'] = PirepPhase::INITIATED;
|
||||
}
|
||||
|
||||
// Default to a scheduled passenger flight
|
||||
@ -162,7 +163,7 @@ class PirepService extends Service
|
||||
->delete();
|
||||
}
|
||||
|
||||
$pirep->status = PirepStatus::INITIATED;
|
||||
$pirep->status = PirepPhase::INITIATED;
|
||||
$pirep->save();
|
||||
$pirep->refresh();
|
||||
|
||||
@ -179,11 +180,44 @@ class PirepService extends Service
|
||||
$this->updateCustomFields($pirep->id, $fields);
|
||||
$this->fareSvc->saveToPirep($pirep, $fares);
|
||||
|
||||
$this->openPositionRow($pirep);
|
||||
|
||||
event(new PirepPrefiled($pirep));
|
||||
|
||||
return $pirep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a prefiled flight on the map, parked at its departure gate. Real
|
||||
* coordinates rather than nulls, so the read path needs no null check.
|
||||
* updateOrCreate because prefiling can land on a reused duplicate leg.
|
||||
*/
|
||||
private function openPositionRow(Pirep $pirep): void
|
||||
{
|
||||
$airport = $pirep->dpt_airport;
|
||||
|
||||
PirepPosition::updateOrCreate(
|
||||
['pirep_id' => $pirep->id],
|
||||
[
|
||||
'user_id' => $pirep->user_id,
|
||||
// Not `pireps`.`status`, which is INITIATED here. Phase and state
|
||||
// are allowed to disagree.
|
||||
'phase' => PirepPhase::SCHEDULED,
|
||||
'lat' => $airport->lat ?? 0,
|
||||
'lon' => $airport->lon ?? 0,
|
||||
'heading' => 0,
|
||||
'distance' => 0,
|
||||
'altitude_agl' => 0,
|
||||
'altitude_msl' => 0,
|
||||
'vs' => 0,
|
||||
'gs' => 0,
|
||||
'ias' => 0,
|
||||
'flight_time' => 0,
|
||||
'fuel_used' => 0,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new PIREP with some given fields
|
||||
*/
|
||||
@ -211,7 +245,7 @@ class PirepService extends Service
|
||||
$pirep->submitted_at = Carbon::now('UTC');
|
||||
}
|
||||
|
||||
$pirep->status = PirepStatus::ARRIVED;
|
||||
$pirep->status = PirepPhase::ARRIVED;
|
||||
|
||||
// Copy some fields over from Flight/SimBrief if we have it
|
||||
if ($pirep->flight) {
|
||||
@ -273,7 +307,7 @@ class PirepService extends Service
|
||||
}
|
||||
|
||||
$attrs['state'] = PirepState::PENDING;
|
||||
$attrs['status'] = PirepStatus::ARRIVED;
|
||||
$attrs['status'] = PirepPhase::ARRIVED;
|
||||
$attrs['submitted_at'] = Carbon::now('UTC');
|
||||
|
||||
$pirep->update($attrs);
|
||||
@ -480,10 +514,14 @@ class PirepService extends Service
|
||||
|
||||
$pirep->update([
|
||||
'state' => PirepState::CANCELLED,
|
||||
'status' => PirepStatus::CANCELLED,
|
||||
'status' => PirepPhase::CANCELLED,
|
||||
]);
|
||||
$pirep->refresh();
|
||||
|
||||
// Synchronous, not left to PirepPositionExpiration: the pilot explicitly
|
||||
// ended this flight and would watch it linger for five minutes otherwise.
|
||||
PirepPosition::where('pirep_id', $pirep->id)->delete();
|
||||
|
||||
event(new PirepCancelled($pirep));
|
||||
|
||||
return $pirep;
|
||||
@ -499,6 +537,7 @@ class PirepService extends Service
|
||||
* pirep_comments
|
||||
* pirep_fares
|
||||
* pirep_field_values
|
||||
* pirep_positions
|
||||
* simbrief
|
||||
*/
|
||||
public function delete(Pirep $pirep): void
|
||||
@ -513,6 +552,12 @@ class PirepService extends Service
|
||||
$this->pirepFinanceSvc->deleteFinancesForPirep($pirep);
|
||||
|
||||
$w = ['pirep_id' => $pirep->id];
|
||||
|
||||
// Listed above since this method was written and never actually
|
||||
// deleted. The FK covers it too, but SQLite can't express that one.
|
||||
Acars::where($w)->delete();
|
||||
PirepPosition::where($w)->delete();
|
||||
|
||||
PirepComment::where($w)->forceDelete();
|
||||
PirepFare::where($w)->forceDelete();
|
||||
PirepFieldValue::where($w)->forceDelete();
|
||||
|
||||
@ -26,10 +26,11 @@ class LiveMap extends Widget
|
||||
{
|
||||
$geoSvc = app(GeoService::class);
|
||||
|
||||
$pireps = Pirep::activeFlights(setting('acars.live_time', 0))->get();
|
||||
// Same source as the API endpoints; this widget bypasses them.
|
||||
$pireps = Pirep::onLiveMap()->get();
|
||||
$positions = $geoSvc->getFeatureForLiveFlights($pireps);
|
||||
|
||||
$center_coords = setting('acars.center_coords', '0,0');
|
||||
$center_coords = setting('livemap.center_coords', '0,0');
|
||||
$center_coords = array_map(fn ($c): float => (float) trim($c), explode(',', $center_coords));
|
||||
|
||||
return view('widgets.live_map', [
|
||||
@ -37,7 +38,7 @@ class LiveMap extends Widget
|
||||
'pireps' => $pireps,
|
||||
'positions' => $positions,
|
||||
'center' => $center_coords,
|
||||
'zoom' => setting('acars.default_zoom', 5),
|
||||
'zoom' => setting('livemap.default_zoom', 5),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,15 +48,23 @@ case "$driver" in
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! docker inspect "$container" >/dev/null 2>&1; then
|
||||
# On .State.Running, not on `docker inspect` succeeding: a container that
|
||||
# exists but is stopped inspects just fine, and the raw "cannot exec in a
|
||||
# stopped container" from the first docker exec is not the help this prints.
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" != "true" ]; then
|
||||
echo "container $container is not running -- start it with:" >&2
|
||||
echo " docker compose -f compose.test.yml up -d $driver" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fires on success, failure and interrupt, so the scratch database does not
|
||||
# outlive the run.
|
||||
trap cleanup EXIT INT TERM
|
||||
# EXIT alone owns the cleanup, so it runs exactly once -- listing INT and TERM
|
||||
# alongside it would fire the handler on the signal and again on the way out.
|
||||
# The signal traps just exit with the conventional 128+signo, which is what
|
||||
# gets the EXIT trap there. An exit code the script does not set itself is
|
||||
# untouched by this, so pest's status still propagates.
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
create
|
||||
vendor/bin/pest -c "phpunit.$driver.xml" "$@"
|
||||
|
||||
@ -183,15 +183,23 @@ return [
|
||||
* Subfleet resolution limits.
|
||||
*
|
||||
* `inherited_list_limit` caps how many bundle-inherited subfleets the
|
||||
* `Flight::withAccessibleSubfleets()` list scope loads per bundle. A list
|
||||
* page fans a bundle's defaults out across every flight on it, so an
|
||||
* uncapped bundle multiplies: 100 subfleets over a 100-flight page is 10k
|
||||
* hydrated rows. Single-flight resolution
|
||||
* (`Flight::accessibleSubfleetsFor()`) is not capped, so a list row can show
|
||||
* fewer subfleets than the flight's own page.
|
||||
* `Flight::withAccessibleSubfleets()` list scope loads per bundle.
|
||||
*
|
||||
* It bounds hydrated PHP objects, not queries — the scope runs a constant
|
||||
* number of queries whatever this is set to, and however many distinct
|
||||
* bundles a page spans. What scales is the per-flight copy the scope makes
|
||||
* of its bundle's subfleets (needed so one flight's fare overrides cannot
|
||||
* leak onto another on the same page): roughly `page size x limit` Subfleet
|
||||
* models, plus their fares. Page size is capped by `pagination.max` above,
|
||||
* so at the default 25 a full 100-flight page tops out around 2,500.
|
||||
*
|
||||
* A bundle trimmed by this cap logs one `debug` line per request naming the
|
||||
* bundle, its accessible subfleet count and the cap. Single-flight
|
||||
* resolution (`Flight::accessibleSubfleetsFor()`) is not capped, so a list
|
||||
* row can show fewer subfleets than the flight's own page.
|
||||
*/
|
||||
'subfleets' => [
|
||||
'inherited_list_limit' => env('PHPVMS_INHERITED_SUBFLEET_LIMIT', 5),
|
||||
'inherited_list_limit' => env('PHPVMS_INHERITED_SUBFLEET_LIMIT', 25),
|
||||
],
|
||||
|
||||
/**
|
||||
|
||||
@ -5,9 +5,9 @@
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Contracts\Factory;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepSource;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\Airline;
|
||||
use App\Models\Flight;
|
||||
@ -71,7 +71,7 @@ class PirepFactory extends Factory
|
||||
),
|
||||
'source_name' => 'TestFactory',
|
||||
'state' => PirepState::PENDING,
|
||||
'status' => PirepStatus::SCHEDULED,
|
||||
'status' => PirepPhase::SCHEDULED,
|
||||
'submitted_at' => Carbon::now('UTC')->toDateTimeString(),
|
||||
'created_at' => Carbon::now('UTC')->toDateTimeString(),
|
||||
'updated_at' => fn (array $pirep) => $pirep['created_at'],
|
||||
|
||||
50
database/factories/PirepPositionFactory.php
Normal file
50
database/factories/PirepPositionFactory.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** @noinspection PhpIllegalPsrClassPathInspection */
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Contracts\Factory;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Models\Pirep;
|
||||
use App\Models\PirepPosition;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<PirepPosition>
|
||||
*/
|
||||
class PirepPositionFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = PirepPosition::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$pirep = Pirep::factory();
|
||||
|
||||
return [
|
||||
'pirep_id' => $pirep,
|
||||
'user_id' => fn (array $attrs): int => Pirep::find($attrs['pirep_id'])->user_id,
|
||||
'phase' => PirepPhase::ENROUTE,
|
||||
'lat' => $this->faker->latitude(),
|
||||
'lon' => $this->faker->longitude(),
|
||||
'heading' => $this->faker->numberBetween(0, 359),
|
||||
'distance' => $this->faker->randomFloat(2, 0, 3000),
|
||||
'altitude_agl' => $this->faker->numberBetween(0, 38000),
|
||||
'altitude_msl' => $this->faker->numberBetween(0, 38000),
|
||||
'vs' => $this->faker->numberBetween(-3000, 3000),
|
||||
'gs' => $this->faker->numberBetween(0, 550),
|
||||
'ias' => $this->faker->numberBetween(0, 350),
|
||||
'flight_time' => $this->faker->numberBetween(0, 600),
|
||||
'fuel_used' => $this->faker->randomFloat(2, 0, 20000),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class() extends Migration
|
||||
{
|
||||
/**
|
||||
* One row per PIREP holding its last-known position. Presence of a row is map
|
||||
* membership. Column names follow `acars` and `pireps`, not the wire protocol,
|
||||
* so units come from DistanceCast/FuelCast. Everything but the timestamps is NOT NULL.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('pirep_positions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('pirep_positions', function (Blueprint $table): void {
|
||||
// Matching `pireps`: MySQL rejects an FK across mismatched collations.
|
||||
$table->collation = 'utf8mb4_unicode_ci';
|
||||
$table->charset = 'utf8mb4';
|
||||
|
||||
// The parent key is the primary key - one row per PIREP is the point.
|
||||
$table->string('pirep_id', 36)->primary();
|
||||
|
||||
// Denormalised for pilot-scoped queries. Safe while a PIREP's owner
|
||||
// cannot change.
|
||||
$table->unsignedInteger('user_id')->index();
|
||||
|
||||
// PirepPhase, same storage as `pireps`.`status`.
|
||||
$table->string('phase', 3)->default('SCH');
|
||||
|
||||
$table->decimal('lat', 10, 5)->default(0);
|
||||
$table->decimal('lon', 11, 5)->default(0);
|
||||
$table->unsignedSmallInteger('heading')->default(0);
|
||||
|
||||
// Plain DOUBLE: UNSIGNED is deprecated in MySQL 8.0.17, and `vs` is signed.
|
||||
$table->double('distance')->default(0);
|
||||
$table->double('altitude_agl')->default(0);
|
||||
$table->double('altitude_msl')->default(0);
|
||||
$table->double('vs')->default(0);
|
||||
|
||||
$table->unsignedInteger('gs')->default(0);
|
||||
$table->unsignedInteger('ias')->default(0);
|
||||
|
||||
// Minutes, matching `pireps`.`flight_time`.
|
||||
$table->unsignedInteger('flight_time')->default(0);
|
||||
$table->decimal('fuel_used')->default(0);
|
||||
|
||||
// `updated_at` is the liveness clock: position batches only, unlike
|
||||
// `pireps`.`updated_at`, which any write bumps.
|
||||
$table->timestamps();
|
||||
|
||||
// At create time, so it lands on SQLite too - unlike the `acars` one.
|
||||
$table->foreign('pirep_id')->references('id')->on('pireps')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('pirep_positions');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class() extends Migration
|
||||
{
|
||||
/**
|
||||
* `acars`.`distance` was an unsigned integer and can't hold a fraction, so it
|
||||
* disagreed with `pirep_positions`.`distance` about the same quantity. Lossless:
|
||||
* unsigned int tops out well inside exact double range.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('acars')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('acars', function (Blueprint $table): void {
|
||||
$table->double('distance')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
/** Back to unsigned integer. Restores the schema, not the lost precision. */
|
||||
public function down(): void
|
||||
{
|
||||
if (!Schema::hasTable('acars')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('acars', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('distance')->nullable()->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class() extends Migration
|
||||
{
|
||||
/**
|
||||
* ⚠ PERMANENTLY DELETES ORPHANED `acars` ROWS. `down()` drops the constraint
|
||||
* and does not bring them back.
|
||||
*
|
||||
* PirepService::delete() has claimed to remove `acars` since it was written and
|
||||
* never did, so most installs carry rows whose PIREP is gone, and the database
|
||||
* rejects the constraint while they exist.
|
||||
*/
|
||||
private const int CHUNK = 10_000;
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('acars') || !Schema::hasTable('pireps')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasForeignKey()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
// Before the sweep, not just the constraint: the anti-join across two
|
||||
// collations is itself error 1267 on MySQL.
|
||||
if (in_array($driver, ['mysql', 'mariadb'], true)) {
|
||||
$this->alignPirepIdCollation();
|
||||
}
|
||||
|
||||
$this->purgeOrphans();
|
||||
|
||||
// SQLite has no ALTER TABLE ADD CONSTRAINT, and Laravel compiles foreign()
|
||||
// against an existing table to nothing, so skip it explicitly. Cleanup there
|
||||
// rests on PirepService::delete().
|
||||
if ($driver === 'sqlite') {
|
||||
Log::info('acars: skipping the pireps foreign key, SQLite cannot add one to an existing table.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('acars', function (Blueprint $table): void {
|
||||
$table->foreign('pirep_id')->references('id')->on('pireps')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/** Drops the constraint. Does NOT restore the rows up() purged. */
|
||||
public function down(): void
|
||||
{
|
||||
if (!Schema::hasTable('acars') || !$this->hasForeignKey()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('acars', function (Blueprint $table): void {
|
||||
$table->dropForeign(['pirep_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/** So a re-run against an already-migrated database is a no-op. */
|
||||
private function hasForeignKey(): bool
|
||||
{
|
||||
return collect(Schema::getForeignKeys('acars'))
|
||||
->flatMap(fn (array $key): array => $key['columns'])
|
||||
->contains('pirep_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched because `acars` is the largest table in a mature install and an
|
||||
* unbounded DELETE can hold locks for minutes. Selected then deleted by primary
|
||||
* key, since a multi-table DELETE takes no LIMIT on MySQL.
|
||||
*/
|
||||
private function purgeOrphans(): void
|
||||
{
|
||||
$orphans = fn () => DB::table('acars')
|
||||
->whereNotNull('acars.pirep_id')
|
||||
->whereNotExists(fn ($query) => $query->select(DB::raw(1))
|
||||
->from('pireps')
|
||||
->whereColumn('pireps.id', 'acars.pirep_id'));
|
||||
|
||||
$total = $orphans()->count();
|
||||
|
||||
if ($total === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::warning('acars: permanently deleting '.$total.' orphaned row(s) whose PIREP no longer exists. This cannot be undone.');
|
||||
|
||||
$deleted = 0;
|
||||
|
||||
while (true) {
|
||||
$ids = $orphans()->limit(self::CHUNK)->pluck('acars.id');
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$deleted += DB::table('acars')->whereIn('id', $ids)->delete();
|
||||
}
|
||||
|
||||
Log::info('acars: purged '.$deleted.' orphaned row(s).');
|
||||
}
|
||||
|
||||
/**
|
||||
* `pireps`.`id` pins utf8mb4_unicode_ci, but an upgraded install may have picked
|
||||
* up the server default for `acars`. Restate the child to match the parent.
|
||||
*/
|
||||
private function alignPirepIdCollation(): void
|
||||
{
|
||||
$connection = Schema::getConnection();
|
||||
$prefix = $connection->getTablePrefix();
|
||||
|
||||
$sql = 'select CHARACTER_SET_NAME as charset, COLLATION_NAME as collation, IS_NULLABLE as nullable
|
||||
from information_schema.COLUMNS
|
||||
where TABLE_SCHEMA = ? and TABLE_NAME = ? and COLUMN_NAME = ?';
|
||||
|
||||
$child = DB::selectOne($sql, [$connection->getDatabaseName(), $prefix.'acars', 'pirep_id']);
|
||||
$parent = DB::selectOne($sql, [$connection->getDatabaseName(), $prefix.'pireps', 'id']);
|
||||
|
||||
if ($child === null || $parent === null || $child->collation === $parent->collation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Identifiers, not values, so they cannot be bound. From information_schema.
|
||||
DB::statement(sprintf(
|
||||
'alter table `%s` modify `pirep_id` varchar(36) character set %s collate %s %s',
|
||||
$prefix.'acars',
|
||||
$parent->charset,
|
||||
$parent->collation,
|
||||
$child->nullable === 'YES' ? 'null' : 'not null'
|
||||
));
|
||||
}
|
||||
};
|
||||
160
database/migrations_data/2026_07_27_000001_live_map_settings.php
Normal file
160
database/migrations_data/2026_07_27_000001_live_map_settings.php
Normal file
@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class() extends Migration
|
||||
{
|
||||
/**
|
||||
* `acars.live_time` decided two unrelated things. It becomes
|
||||
* `pireps.tombstone_time` - the reaper's half - keeping hours and 12 so stored
|
||||
* values aren't reinterpreted. `livemap.live_time` and `livemap.idle_time` take
|
||||
* the map's half and are new, so the seeder owns them. The rest is a regroup.
|
||||
*
|
||||
* @var list<array{old: string, new: string, name: string, group: string, type: string, default: string, description: string}>
|
||||
*/
|
||||
private array $renames = [
|
||||
[
|
||||
'old' => 'acars.live_time',
|
||||
'new' => 'pireps.tombstone_time',
|
||||
'name' => 'Tombstone Time',
|
||||
'group' => 'pireps',
|
||||
'type' => 'int',
|
||||
'default' => '12',
|
||||
'description' => 'How long an in-progress PIREP that has stopped reporting survives before it is cancelled, in hours. Set to 0 to never cancel a PIREP on account of age',
|
||||
],
|
||||
[
|
||||
'old' => 'acars.center_coords',
|
||||
'new' => 'livemap.center_coords',
|
||||
'name' => 'Center Coords',
|
||||
'group' => 'livemap',
|
||||
'type' => 'text',
|
||||
'default' => '30.1945,-97.6699',
|
||||
'description' => 'Where to center the map; enter as LAT,LON',
|
||||
],
|
||||
[
|
||||
'old' => 'acars.default_zoom',
|
||||
'new' => 'livemap.default_zoom',
|
||||
'name' => 'Default Zoom',
|
||||
'group' => 'livemap',
|
||||
'type' => 'int',
|
||||
'default' => '5',
|
||||
'description' => 'Initial zoom level on the map',
|
||||
],
|
||||
[
|
||||
'old' => 'acars.update_interval',
|
||||
'new' => 'livemap.update_interval',
|
||||
'name' => 'Refresh Interval',
|
||||
'group' => 'livemap',
|
||||
'type' => 'int',
|
||||
'default' => '60',
|
||||
'description' => 'How often the live map updates its data',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* New here, so reversing removes them rather than renaming them back.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private array $introduced = [
|
||||
'livemap.live_time',
|
||||
'livemap.idle_time',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('settings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->renames as $rename) {
|
||||
$this->move(
|
||||
from: $rename['old'],
|
||||
to: $rename['new'],
|
||||
name: $rename['name'],
|
||||
group: $rename['group'],
|
||||
type: $rename['type'],
|
||||
default: $rename['default'],
|
||||
description: $rename['description'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (!Schema::hasTable('settings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->renames as $rename) {
|
||||
$this->move(
|
||||
from: $rename['new'],
|
||||
to: $rename['old'],
|
||||
name: $rename['old'] === 'acars.live_time' ? 'Live Time' : $rename['name'],
|
||||
group: 'acars',
|
||||
type: $rename['type'],
|
||||
default: $rename['default'],
|
||||
description: $rename['old'] === 'acars.live_time'
|
||||
? 'Age of flights to show on the map in hours. Set to 0 to show only all in-progress flights'
|
||||
: $rename['description'],
|
||||
);
|
||||
}
|
||||
|
||||
// No pre-change counterpart to restore to.
|
||||
foreach ($this->introduced as $key) {
|
||||
Setting::where('id', Setting::formatKey($key))->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renamed in place, not recreated: `value` is not among the columns written, so
|
||||
* it survives by construction. The discord migration's copy-when-blank rule would
|
||||
* never fire here, since every setting moved seeds to a real default.
|
||||
*
|
||||
* The seeder runs first, so the destination usually exists. Its placement is
|
||||
* adopted and the row dropped, leaving upgrades and fresh installs identical
|
||||
* except for the operator's value. Idempotent: no source, nothing to do.
|
||||
*/
|
||||
private function move(
|
||||
string $from,
|
||||
string $to,
|
||||
string $name,
|
||||
string $group,
|
||||
string $type,
|
||||
string $default,
|
||||
string $description,
|
||||
): void {
|
||||
$source = Setting::where('id', Setting::formatKey($from))->first();
|
||||
|
||||
if (!$source instanceof Setting) {
|
||||
return;
|
||||
}
|
||||
|
||||
$target = Setting::where('id', Setting::formatKey($to))->first();
|
||||
|
||||
$offset = $target->offset ?? $source->offset;
|
||||
$order = $target->order ?? $source->order;
|
||||
|
||||
$target?->delete();
|
||||
|
||||
// Query builder because this rewrites the primary key, and the explicit
|
||||
// column list is what documents that `value` is not among them.
|
||||
DB::table('settings')
|
||||
->where('id', Setting::formatKey($from))
|
||||
->update([
|
||||
'id' => Setting::formatKey($to),
|
||||
'key' => $to,
|
||||
'name' => $name,
|
||||
'group' => $group,
|
||||
'type' => $type,
|
||||
'options' => '',
|
||||
'default' => $default,
|
||||
'description' => $description,
|
||||
'offset' => $offset,
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
};
|
||||
@ -15,9 +15,9 @@ declare(strict_types=1);
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\PirepFieldSource;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepSource;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\Airport;
|
||||
use App\Models\Pirep;
|
||||
@ -29,6 +29,7 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Override;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
|
||||
class SampleDataSeeder extends YamlSeeder
|
||||
@ -39,7 +40,7 @@ class SampleDataSeeder extends YamlSeeder
|
||||
parent::__construct($this->yamlDbSvc);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public function run(): void
|
||||
{
|
||||
$seedPath = database_path('seeders/sample');
|
||||
@ -172,7 +173,7 @@ class SampleDataSeeder extends YamlSeeder
|
||||
'source' => 0,
|
||||
'source_name' => $bulkSeedMarker,
|
||||
'state' => $state->value,
|
||||
'status' => PirepStatus::ARRIVED->value,
|
||||
'status' => PirepPhase::ARRIVED->value,
|
||||
'notes' => 'Seeded PIREP #'.$flightNum,
|
||||
'block_off_time' => $blockOffStr,
|
||||
'block_on_time' => $blockOnStr,
|
||||
@ -227,7 +228,7 @@ class SampleDataSeeder extends YamlSeeder
|
||||
'source' => PirepSource::ACARS->value,
|
||||
'source_name' => 'vmsacars',
|
||||
'state' => PirepState::ACCEPTED->value,
|
||||
'status' => PirepStatus::ARRIVED->value,
|
||||
'status' => PirepPhase::ARRIVED->value,
|
||||
'block_off_time' => '2026-05-16 14:45:46',
|
||||
'block_on_time' => '2026-05-16 15:34:45',
|
||||
'submitted_at' => '2026-05-16 15:35:12',
|
||||
|
||||
@ -230,38 +230,47 @@ class SettingsSeeder extends Seeder
|
||||
'description' => 'The units for temperature',
|
||||
],
|
||||
|
||||
// ACARS
|
||||
// Live map
|
||||
[
|
||||
'key' => 'acars.live_time',
|
||||
'name' => 'Live Time',
|
||||
'group' => 'acars',
|
||||
'value' => '12',
|
||||
'key' => 'livemap.live_time',
|
||||
'name' => 'Completed Flight Time',
|
||||
'group' => 'livemap',
|
||||
'value' => '30',
|
||||
'type' => 'int',
|
||||
'options' => '',
|
||||
'description' => 'Age of flights to show on the map in hours. Set to 0 to show only all in-progress flights',
|
||||
'description' => 'How long a flight stays on the map after it stops being in progress, in minutes',
|
||||
],
|
||||
[
|
||||
'key' => 'acars.center_coords',
|
||||
'key' => 'livemap.idle_time',
|
||||
'name' => 'Stationary Flight Time',
|
||||
'group' => 'livemap',
|
||||
'value' => '60',
|
||||
'type' => 'int',
|
||||
'options' => '',
|
||||
'description' => 'How long a flight that is not moving stays on the map, in minutes. Covers a paused flight and one that has been prefiled but has not yet departed',
|
||||
],
|
||||
[
|
||||
'key' => 'livemap.center_coords',
|
||||
'name' => 'Center Coords',
|
||||
'group' => 'acars',
|
||||
'group' => 'livemap',
|
||||
'value' => '30.1945,-97.6699',
|
||||
'type' => 'text',
|
||||
'options' => '',
|
||||
'description' => 'Where to center the map; enter as LAT,LON',
|
||||
],
|
||||
[
|
||||
'key' => 'acars.default_zoom',
|
||||
'key' => 'livemap.default_zoom',
|
||||
'name' => 'Default Zoom',
|
||||
'group' => 'acars',
|
||||
'group' => 'livemap',
|
||||
'value' => '5',
|
||||
'type' => 'int',
|
||||
'options' => '',
|
||||
'description' => 'Initial zoom level on the map',
|
||||
],
|
||||
[
|
||||
'key' => 'acars.update_interval',
|
||||
'key' => 'livemap.update_interval',
|
||||
'name' => 'Refresh Interval',
|
||||
'group' => 'acars',
|
||||
'group' => 'livemap',
|
||||
'value' => '60',
|
||||
'type' => 'int',
|
||||
'options' => '',
|
||||
@ -556,6 +565,15 @@ class SettingsSeeder extends Seeder
|
||||
'options' => 'true,false',
|
||||
'description' => 'Enables remaining fuel amounts to be considered for fuel expenses',
|
||||
],
|
||||
[
|
||||
'key' => 'pireps.tombstone_time',
|
||||
'name' => 'Tombstone Time',
|
||||
'group' => 'pireps',
|
||||
'value' => '12',
|
||||
'type' => 'int',
|
||||
'options' => '',
|
||||
'description' => 'How long an in-progress PIREP that has stopped reporting survives before it is cancelled, in hours. Set to 0 to never cancel a PIREP on account of age',
|
||||
],
|
||||
[
|
||||
'key' => 'pireps.delete_cancelled_hours',
|
||||
'name' => 'Delete cancelled PIREPs',
|
||||
|
||||
@ -1,5 +1,80 @@
|
||||
# Upgrading phpvms
|
||||
|
||||
## Unreleased — live map positions
|
||||
|
||||
The live map now reads from a new `pirep_positions` table — one row per flight —
|
||||
instead of resolving the newest `acars` breadcrumb for every flight on every
|
||||
poll. A row in that table _is_ what puts a flight on the map.
|
||||
|
||||
### ⚠ Orphaned ACARS rows are permanently deleted
|
||||
|
||||
`PirepService::delete()` has always listed `acars` as a child table it removes,
|
||||
and never removed it. There was no foreign key either, so **every install that
|
||||
has ever hard-deleted a PIREP is carrying `acars` rows whose PIREP no longer
|
||||
exists.** `add_acars_pirep_foreign_key` counts those rows, logs the number, and
|
||||
deletes them in batches of 10,000 before adding the constraint — the database
|
||||
rejects the constraint while they exist.
|
||||
|
||||
**This cannot be undone.** Rolling the migration back drops the foreign key; it
|
||||
does not bring the rows back. Take a backup first if you want them.
|
||||
|
||||
The rows were already unreachable: nothing joins `acars` to a PIREP that is not
|
||||
there. Soft-deleted PIREPs are _not_ affected — the PIREP row still exists, so
|
||||
its telemetry is not orphaned.
|
||||
|
||||
On a synthetic 2,000,000-row `acars` table with 1,000,000 orphans the whole
|
||||
migration took about 45 seconds on MySQL 8. Time scales with your orphan count,
|
||||
which nothing can predict in advance.
|
||||
|
||||
SQLite cannot add a foreign key to an existing table, so the constraint is
|
||||
skipped there and telemetry cleanup relies on the service layer alone. The
|
||||
orphan purge and the column widening still apply.
|
||||
|
||||
### Behaviour changes you will notice
|
||||
|
||||
- **Prefiled flights now appear on the map before they move**, stationary at
|
||||
their departure airport. Previously a flight was invisible until its first
|
||||
position report.
|
||||
- **Completed and paused flights stay on the map for a configured period.**
|
||||
Previously a completed flight vanished the instant its PIREP left
|
||||
`IN_PROGRESS`.
|
||||
- **An administrator editing a PIREP no longer keeps a dead flight on the map.**
|
||||
Liveness is now measured on the position row, which only position reports
|
||||
touch.
|
||||
|
||||
### Settings
|
||||
|
||||
`acars.live_time` did two unrelated jobs. It has been split, and the live map's
|
||||
display settings have moved out of the ACARS group into a new **Live map** group.
|
||||
Your configured values are carried across automatically.
|
||||
|
||||
| Old key | New key | Unit | Default |
|
||||
| ----------------------- | ------------------------- | ------- | ------- |
|
||||
| `acars.live_time` | `pireps.tombstone_time` | hours | 12 |
|
||||
| — | `livemap.live_time` | minutes | 30 |
|
||||
| — | `livemap.idle_time` | minutes | 60 |
|
||||
| `acars.center_coords` | `livemap.center_coords` | | |
|
||||
| `acars.default_zoom` | `livemap.default_zoom` | | |
|
||||
| `acars.update_interval` | `livemap.update_interval` | | |
|
||||
|
||||
`pireps.tombstone_time` keeps hours and keeps your number — it governs only when
|
||||
a silent in-progress PIREP is cancelled. The two new settings are in minutes and
|
||||
govern only the map: how long a finished flight stays drawn, and how long a
|
||||
flight that is not moving stays drawn (a paused one, or one prefiled and not yet
|
||||
departed).
|
||||
|
||||
### For addon authors
|
||||
|
||||
`App\Enums\PirepStatus` is deprecated in favour of `App\Enums\PirepPhase`. It is
|
||||
a `class_alias`, not a second enum, so `PirepStatus::TAXI` and
|
||||
`PirepPhase::TAXI` are the same case — identity comparison, `instanceof` and
|
||||
existing model casts all keep working, and no stored value changes. No database
|
||||
column was renamed; `pireps`.`status` and `acars`.`status` are untouched.
|
||||
|
||||
`Pirep::position()` now returns a `PirepPosition`, not an `Acars`.
|
||||
`Pirep::scopeActiveFlights()` is gone: use `Pirep::onLiveMap()` for the map, or
|
||||
`Pirep::silentInProgress($hours)` for the reaper's meaning.
|
||||
|
||||
## Unreleased — Laravel Passport (OAuth2) API authentication
|
||||
|
||||
The API can now be authenticated with OAuth2 (Laravel Passport) in addition to
|
||||
|
||||
@ -18,10 +18,10 @@ import "chartjs-adapter-date-fns";
|
||||
|
||||
Chart.register(annotationPlugin);
|
||||
|
||||
// Phase shading keyed off ACARS sample `status` (PirepStatus enum value).
|
||||
// Phase shading keyed off ACARS sample `status` (PirepPhase enum value).
|
||||
// Codes that don't appear here render unshaded — keeps unknown / SCH from
|
||||
// painting the whole chart gray. Labels come from the server payload
|
||||
// (`phase.label`) so translations stay in PHP land (PirepStatus::getLabel).
|
||||
// (`phase.label`) so translations stay in PHP land (PirepPhase::getLabel).
|
||||
// Low-alpha backgrounds so the data line stays visually dominant.
|
||||
const PHASE_COLORS = {
|
||||
// Ground / pre-flight
|
||||
|
||||
@ -40,6 +40,8 @@ return [
|
||||
'click_update_to_run' => 'Click "Update" to run the script.',
|
||||
'update' => 'Update',
|
||||
'migrations_not_completed' => 'You still have :count migrations to run. Please try again...',
|
||||
'pause' => 'Pause',
|
||||
'resume' => 'Resume',
|
||||
'user_and_airline_setup' => 'User & Airline Setup',
|
||||
'legacy_importer' => 'phpvms v5 Legacy Importer',
|
||||
'super_admin_information' => 'Super Admin User Information',
|
||||
|
||||
@ -43,7 +43,7 @@ return [
|
||||
'user_registered' => 'New User Registered: :ident',
|
||||
|
||||
/*
|
||||
* Verbs completing "Flight <ident> ...", keyed by PirepStatus value.
|
||||
* Verbs completing "Flight <ident> ...", keyed by PirepPhase value.
|
||||
*/
|
||||
'status' => [
|
||||
'INI' => 'is initialized',
|
||||
|
||||
87
resources/views/filament/installer/auto-advance.blade.php
Normal file
87
resources/views/filament/installer/auto-advance.blade.php
Normal file
@ -0,0 +1,87 @@
|
||||
{{--
|
||||
Drives the installer's "advance to the next step on its own" countdown.
|
||||
|
||||
The two controls it talks to — the wizard's Pause and Next buttons — are
|
||||
rendered in the wizard footer, which sits outside this step's schema, so the
|
||||
shared countdown state lives in a global Alpine store rather than an x-data
|
||||
scope on a common ancestor.
|
||||
--}}
|
||||
<script>
|
||||
(() => {
|
||||
if (window.installerAutoAdvanceRegistered) {
|
||||
return
|
||||
}
|
||||
|
||||
window.installerAutoAdvanceRegistered = true
|
||||
|
||||
const store = () => window.Alpine?.store('installerAutoAdvance')
|
||||
|
||||
const register = () => {
|
||||
window.Alpine.store('installerAutoAdvance', {
|
||||
active: false,
|
||||
paused: false,
|
||||
remaining: 0,
|
||||
timer: null,
|
||||
|
||||
start(seconds) {
|
||||
if (this.active) {
|
||||
return
|
||||
}
|
||||
|
||||
this.active = true
|
||||
this.paused = false
|
||||
this.remaining = seconds
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
if (this.paused) {
|
||||
return
|
||||
}
|
||||
|
||||
this.remaining--
|
||||
|
||||
if (this.remaining <= 0) {
|
||||
this.advance()
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector('.fi-sc-wizard-footer')
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
},
|
||||
|
||||
toggle() {
|
||||
this.paused = ! this.paused
|
||||
},
|
||||
|
||||
advance() {
|
||||
this.stop()
|
||||
|
||||
document.querySelector('[data-installer-next]')?.click()
|
||||
},
|
||||
|
||||
stop() {
|
||||
clearInterval(this.timer)
|
||||
|
||||
this.timer = null
|
||||
this.active = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', register)
|
||||
|
||||
if (window.Alpine) {
|
||||
register()
|
||||
}
|
||||
|
||||
window.addEventListener('installer-migrations-complete', (event) => {
|
||||
store()?.start(event.detail?.seconds ?? {{ $seconds }})
|
||||
})
|
||||
|
||||
// Covers both the countdown firing and the user clicking Next early, so
|
||||
// the paused countdown can't linger into the following step.
|
||||
window.addEventListener('next-wizard-step', () => store()?.stop())
|
||||
})()
|
||||
</script>
|
||||
@ -177,15 +177,15 @@
|
||||
<span>@lang('common.status')</span>
|
||||
@php
|
||||
$statusClass = 'bg-info';
|
||||
if ($pirep->status === PirepStatus::SCHEDULED) {
|
||||
if ($pirep->status === PirepPhase::SCHEDULED) {
|
||||
$statusClass = 'bg-secondary';
|
||||
} elseif ($pirep->status === PirepStatus::ENROUTE) {
|
||||
} elseif ($pirep->status === PirepPhase::ENROUTE) {
|
||||
$statusClass = 'bg-primary';
|
||||
} elseif ($pirep->status === PirepStatus::ARRIVED) {
|
||||
} elseif ($pirep->status === PirepPhase::ARRIVED) {
|
||||
$statusClass = 'bg-success';
|
||||
} elseif ($pirep->status === PirepStatus::CANCELLED) {
|
||||
} elseif ($pirep->status === PirepPhase::CANCELLED) {
|
||||
$statusClass = 'bg-danger';
|
||||
} elseif ($pirep->status === PirepStatus::DIVERTED) {
|
||||
} elseif ($pirep->status === PirepPhase::DIVERTED) {
|
||||
$statusClass = 'bg-warning';
|
||||
}
|
||||
@endphp
|
||||
|
||||
@ -134,7 +134,7 @@ and being mindful of the rivets bindings
|
||||
center: ['{{ $center[0] }}', '{{ $center[1] }}'],
|
||||
zoom: '{{ $zoom }}',
|
||||
aircraft_icon: '{!! public_asset('/assets/img/acars/aircraft.png') !!}',
|
||||
refresh_interval: {{ setting('acars.update_interval', 60) }},
|
||||
refresh_interval: {{ setting('livemap.update_interval', 60) }},
|
||||
units: '{{ setting('units.distance') }}',
|
||||
flown_route_color: '#067ec1',
|
||||
leafletOptions: {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Exceptions\AircraftNotAtAirport;
|
||||
use App\Exceptions\UserNotAtAirport;
|
||||
use App\Models\Acars;
|
||||
@ -366,7 +366,7 @@ it('can receive acars updates', function (): void {
|
||||
'level' => 38000,
|
||||
'planned_distance' => 400,
|
||||
'planned_flight_time' => 120,
|
||||
'status' => PirepStatus::BOARDING->value,
|
||||
'status' => PirepPhase::BOARDING->value,
|
||||
'route' => 'POINTA POINTB',
|
||||
'source_name' => 'AcarsTest::testAcarsUpdates',
|
||||
'fields' => [
|
||||
@ -388,7 +388,7 @@ it('can receive acars updates', function (): void {
|
||||
// Check the PIREP state and status
|
||||
$pirep = getPirepFromApi($pirep_id);
|
||||
expect(PirepState::from($pirep['state']))->toEqual(PirepState::IN_PROGRESS)
|
||||
->and(PirepStatus::from($pirep['status']))->toEqual(PirepStatus::INITIATED)
|
||||
->and(PirepPhase::from($pirep['status']))->toEqual(PirepPhase::INITIATED)
|
||||
->and($pirep)->toHaveKey('fields')
|
||||
->and($pirep['fields']['custom_field'])->toEqual('custom_value')
|
||||
->and($pirep['planned_distance']['nmi'])->toEqual($pirep_create['planned_distance'])
|
||||
@ -405,7 +405,7 @@ it('can receive acars updates', function (): void {
|
||||
$this->post($uri, [
|
||||
'flight_time' => 60,
|
||||
'distance' => 20,
|
||||
'status' => PirepStatus::AIRBORNE->value,
|
||||
'status' => PirepPhase::AIRBORNE->value,
|
||||
'fields' => [
|
||||
'custom_field' => 'custom_value_changed',
|
||||
],
|
||||
@ -443,7 +443,7 @@ it('can receive acars updates', function (): void {
|
||||
// Make sure PIREP state moved into ENROUTE
|
||||
$pirep = getPirepFromApi($pirep_id);
|
||||
expect(PirepState::from($pirep['state']))->toEqual(PirepState::IN_PROGRESS)
|
||||
->and(PirepStatus::from($pirep['status']))->toEqual(PirepStatus::AIRBORNE);
|
||||
->and(PirepPhase::from($pirep['status']))->toEqual(PirepPhase::AIRBORNE);
|
||||
|
||||
$response = $this->get($uri);
|
||||
$response->assertStatus(200);
|
||||
@ -542,7 +542,7 @@ test('multiple altitudes', function (): void {
|
||||
'level' => 38000,
|
||||
'planned_distance' => 400,
|
||||
'planned_flight_time' => 120,
|
||||
'status' => PirepStatus::BOARDING->value,
|
||||
'status' => PirepPhase::BOARDING->value,
|
||||
'route' => 'POINTA POINTB',
|
||||
'source_name' => 'AcarsTest::testAcarsUpdates',
|
||||
'fields' => [
|
||||
|
||||
@ -10,24 +10,20 @@ use App\Models\Subfleet;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Regression: Flight::accessibleSubfleetsFor applies the access filter BEFORE
|
||||
* testing whether the flight has pinned subfleets (app/Models/Flight.php:557-565):
|
||||
* Regression cover for the ordering of eligibility against access.
|
||||
*
|
||||
* $pinned = Subfleet::query()
|
||||
* ->allowedFor($user) // <-- access filter first
|
||||
* ->whereHas('flights', ...)
|
||||
* ->get();
|
||||
* `Flight::accessibleSubfleetsFor()` used to narrow by `allowedFor($user)`
|
||||
* before asking whether the flight had any pins, then read an empty result as
|
||||
* "this flight pins nothing" and fall through to the fallback. A pilot
|
||||
* unqualified for a flight's designated aircraft was therefore offered the
|
||||
* entire rest of the fleet, on a flight that had been explicitly restricted
|
||||
* away from them — the failure ran in the widening direction, which is the
|
||||
* dangerous one.
|
||||
*
|
||||
* if ($pinned->isNotEmpty()) { return $pinned; }
|
||||
* // ...otherwise fall through to every subfleet the user can access
|
||||
*
|
||||
* So a pilot who is not qualified for a flight's designated aircraft does not
|
||||
* get an empty list — they fall through to the unbounded fallback and are
|
||||
* offered the whole rest of the fleet, on a flight that was explicitly
|
||||
* restricted away from them.
|
||||
*
|
||||
* Correct behaviour: eligibility is resolved first, access filtering second.
|
||||
* A flight with pins the pilot cannot use resolves to nothing.
|
||||
* It now decides which rung applies from configuration alone and narrows the
|
||||
* winning rung by access afterwards, so a rung narrowed to nothing stays
|
||||
* empty. These tests hold that line: each fails if the two steps are swapped
|
||||
* back.
|
||||
*/
|
||||
test('a pilot unqualified for a flights only pinned subfleet is offered nothing', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', true);
|
||||
|
||||
@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Bundle-level subfleet defaults as seen through the LIST path — the
|
||||
@ -296,7 +297,9 @@ test('an unusable configured cap falls back instead of emptying the rung', funct
|
||||
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
|
||||
|
||||
$bundle = FlightBundle::factory()->create();
|
||||
$subfleets = collect(range(1, 6))
|
||||
// One more than the effective cap, so a fallback that resolved to the wrong
|
||||
// number — not just to zero — is caught too.
|
||||
$subfleets = collect(range(1, $expected + 1))
|
||||
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$rank]));
|
||||
$bundle->subfleets()->attach($subfleets->pluck('id')->all());
|
||||
|
||||
@ -305,18 +308,194 @@ test('an unusable configured cap falls back instead of emptying the rung', funct
|
||||
// `limit(0)` compiles to `row_num <= 0`, which matches nothing: read
|
||||
// without a default, an absent or empty setting does not fall back, it
|
||||
// switches bundle inheritance off for the whole site and does it quietly.
|
||||
// The named default and the `?:` fallback are two separate literals in the
|
||||
// scope and both have to say 25 — 'key absent' exercises the first, the
|
||||
// rest exercise the second.
|
||||
expect(throughScope($flight, $user)->subfleets->pluck('id')->all())
|
||||
->toBe($subfleets->pluck('id')->sort()->values()->take($expected)->all());
|
||||
})->with([
|
||||
// A config cache built before this release carries no key at all.
|
||||
'key absent' => [[], 5],
|
||||
'null' => [['inherited_list_limit' => null], 5],
|
||||
'key absent' => [[], 25],
|
||||
'null' => [['inherited_list_limit' => null], 25],
|
||||
// `PHPVMS_INHERITED_SUBFLEET_LIMIT=` in .env reads back as ''.
|
||||
'empty string' => [['inherited_list_limit' => ''], 5],
|
||||
'zero' => [['inherited_list_limit' => 0], 5],
|
||||
'empty string' => [['inherited_list_limit' => ''], 25],
|
||||
'zero' => [['inherited_list_limit' => 0], 25],
|
||||
'negative' => [['inherited_list_limit' => -1], 1],
|
||||
]);
|
||||
|
||||
test('the shipped default cap is 25', function (): void {
|
||||
// The dataset above proves the scope's own fallbacks; this pins the value
|
||||
// config/phpvms.php actually ships, which is what an untouched install gets.
|
||||
expect(config('phpvms.subfleets.inherited_list_limit'))->toBe(25);
|
||||
});
|
||||
|
||||
/**
|
||||
* The line `withAccessibleSubfleets` emits for a truncated bundle. Stated once
|
||||
* so a message change cannot pass by being wrong in the test as well.
|
||||
*/
|
||||
function truncationLine(FlightBundle $bundle, int $accessible, int $limit): string
|
||||
{
|
||||
return 'Flight list: bundle '.$bundle->id.' has '.$accessible
|
||||
.' accessible subfleets, showing the first '.$limit
|
||||
.' (phpvms.subfleets.inherited_list_limit)';
|
||||
}
|
||||
|
||||
test('a bundle inside the cap says nothing', function (): void {
|
||||
config(['phpvms.subfleets.inherited_list_limit' => 3]);
|
||||
|
||||
$airline = Airline::factory()->create();
|
||||
$rank = Rank::factory()->create(['name' => 'Line']);
|
||||
|
||||
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
|
||||
|
||||
$bundle = FlightBundle::factory()->create();
|
||||
$subfleets = collect(range(1, 3))
|
||||
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$rank]));
|
||||
$bundle->subfleets()->attach($subfleets->pluck('id')->all());
|
||||
|
||||
$flight = Flight::factory()->create(['airline_id' => $airline->id, 'bundle_id' => $bundle->id]);
|
||||
|
||||
Log::spy();
|
||||
|
||||
// Exactly at the cap is the boundary that matters: nothing was dropped, so
|
||||
// a `>=` in the scope would cry wolf on every correctly configured bundle
|
||||
// that happens to sit on the limit.
|
||||
expect(throughScope($flight, $user)->subfleets)->toHaveCount(3);
|
||||
|
||||
Log::shouldNotHaveReceived('debug');
|
||||
});
|
||||
|
||||
test('a bundle over the cap says so once, naming the bundle, the count and the cap', function (): void {
|
||||
config(['phpvms.subfleets.inherited_list_limit' => 2]);
|
||||
|
||||
$airline = Airline::factory()->create();
|
||||
$junior = Rank::factory()->create(['name' => 'Junior']);
|
||||
$senior = Rank::factory()->create(['name' => 'Senior']);
|
||||
|
||||
$user = User::factory()->create(['rank_id' => $junior->id, 'airline_id' => $airline->id]);
|
||||
|
||||
$bundle = FlightBundle::factory()->create();
|
||||
$reachable = collect(range(1, 4))
|
||||
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$junior, $senior]));
|
||||
// Configured on the bundle but unflyable by this pilot. It is not part of
|
||||
// what was trimmed from his list, so counting it would report a truncation
|
||||
// one wider than the one he is actually seeing.
|
||||
$unreachable = listableSubfleet($airline, 'Senior Only', [$senior]);
|
||||
$bundle->subfleets()->attach($reachable->pluck('id')->push($unreachable->id)->all());
|
||||
|
||||
$flight = Flight::factory()->create(['airline_id' => $airline->id, 'bundle_id' => $bundle->id]);
|
||||
|
||||
Log::spy();
|
||||
|
||||
expect(throughScope($flight, $user)->subfleets->pluck('id')->all())
|
||||
->toBe($reachable->pluck('id')->sort()->values()->take(2)->all());
|
||||
|
||||
// The dropped rows are the highest ids — the subfleet an admin just added
|
||||
// and is now hunting for. Without the count the message cannot say how much
|
||||
// is missing, and without the cap it cannot say which setting to raise.
|
||||
Log::shouldHaveReceived('debug')->once()->with(truncationLine($bundle, 4, 2));
|
||||
});
|
||||
|
||||
test('a hundred flights on one over capped bundle log one line, not a hundred', function (): void {
|
||||
config(['phpvms.subfleets.inherited_list_limit' => 2]);
|
||||
|
||||
$airline = Airline::factory()->create();
|
||||
$rank = Rank::factory()->create(['name' => 'Line']);
|
||||
|
||||
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
|
||||
|
||||
$bundle = FlightBundle::factory()->create();
|
||||
$subfleets = collect(range(1, 5))
|
||||
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$rank]));
|
||||
$bundle->subfleets()->attach($subfleets->pluck('id')->all());
|
||||
|
||||
$flights = Flight::factory()->count(100)->create([
|
||||
'airline_id' => $airline->id,
|
||||
'bundle_id' => $bundle->id,
|
||||
]);
|
||||
|
||||
Log::spy();
|
||||
|
||||
$got = Flight::query()
|
||||
->whereIn('id', $flights->pluck('id')->all())
|
||||
->withAccessibleSubfleets($user)
|
||||
->get();
|
||||
|
||||
expect($got)->toHaveCount(100)
|
||||
->and($got->every(fn (Flight $flight): bool => $flight->subfleets->count() === 2))->toBeTrue();
|
||||
|
||||
// One hydrated Bundle backs all 100 rows, so the naive "log where you
|
||||
// truncate" reading of this turns a single misconfiguration into a
|
||||
// hundred-line burst on every page load. Dedupe is inside the afterQuery
|
||||
// callback, which sees the whole page at once.
|
||||
Log::shouldHaveReceived('debug')->once()->with(truncationLine($bundle, 5, 2));
|
||||
});
|
||||
|
||||
test('applying the scope twice still logs the truncation once', function (): void {
|
||||
config(['phpvms.subfleets.inherited_list_limit' => 1]);
|
||||
|
||||
$airline = Airline::factory()->create();
|
||||
$rank = Rank::factory()->create(['name' => 'Line']);
|
||||
|
||||
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
|
||||
|
||||
$bundle = FlightBundle::factory()->create();
|
||||
$subfleets = collect(range(1, 3))
|
||||
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$rank]));
|
||||
$bundle->subfleets()->attach($subfleets->pluck('id')->all());
|
||||
|
||||
$flight = Flight::factory()->create(['airline_id' => $airline->id, 'bundle_id' => $bundle->id]);
|
||||
|
||||
Log::spy();
|
||||
|
||||
// Two stacked afterQuery callbacks each walk the page with their own dedupe
|
||||
// set. The count being consumed on the first pass is what keeps the second
|
||||
// quiet, exactly as the consumed has-pins probe does above.
|
||||
Flight::query()
|
||||
->whereKey($flight->id)
|
||||
->withAccessibleSubfleets($user)
|
||||
->withAccessibleSubfleets($user)
|
||||
->get();
|
||||
|
||||
Log::shouldHaveReceived('debug')->once()->with(truncationLine($bundle, 3, 1));
|
||||
});
|
||||
|
||||
test('the truncation count never reaches a bundle the caller kept', function (): void {
|
||||
config(['phpvms.subfleets.inherited_list_limit' => 2]);
|
||||
|
||||
$airline = Airline::factory()->create();
|
||||
$rank = Rank::factory()->create(['name' => 'Line']);
|
||||
|
||||
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
|
||||
|
||||
$bundle = FlightBundle::factory()->create();
|
||||
$subfleets = collect(range(1, 4))
|
||||
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'Sub '.$i, [$rank]));
|
||||
$bundle->subfleets()->attach($subfleets->pluck('id')->all());
|
||||
|
||||
$flights = collect(range(1, 2))->map(fn (): Flight => Flight::factory()->create([
|
||||
'airline_id' => $airline->id,
|
||||
'bundle_id' => $bundle->id,
|
||||
]));
|
||||
|
||||
$got = Flight::query()
|
||||
->with('bundle')
|
||||
->whereIn('id', $flights->pluck('id')->all())
|
||||
->withAccessibleSubfleets($user)
|
||||
->get();
|
||||
|
||||
// The count is a `withCount` subselect, so it lands as a plain attribute on
|
||||
// the bundle and would serialise straight through `parent::toArray()` on
|
||||
// any endpoint that publishes the bundle. It is this scope's working state,
|
||||
// and gets stripped alongside the has-pins probe — but the bundle survives,
|
||||
// because the caller asked for it.
|
||||
foreach ($got as $flight) {
|
||||
expect($flight->relationLoaded('bundle'))->toBeTrue()
|
||||
->and($flight->bundle->getAttributes())->not->toHaveKey('subfleets_count')
|
||||
->and($flight->bundle->toArray())->not->toHaveKey('subfleets_count');
|
||||
}
|
||||
});
|
||||
|
||||
test('the per bundle cap orders inside its window function', function (): void {
|
||||
$airline = Airline::factory()->create();
|
||||
$rank = Rank::factory()->create(['name' => 'Line']);
|
||||
@ -356,6 +535,67 @@ test('the per bundle cap orders inside its window function', function (): void {
|
||||
);
|
||||
});
|
||||
|
||||
test('the scope costs a fixed number of queries however many flights and bundles a page spans', function (): void {
|
||||
$airline = Airline::factory()->create();
|
||||
$rank = Rank::factory()->create(['name' => 'Line']);
|
||||
|
||||
$user = User::factory()->create(['rank_id' => $rank->id, 'airline_id' => $airline->id]);
|
||||
|
||||
$page = function (int $bundleCount, int $flightsPerBundle) use ($airline, $rank): array {
|
||||
$ids = [];
|
||||
|
||||
foreach (range(1, $bundleCount) as $b) {
|
||||
$bundle = FlightBundle::factory()->create();
|
||||
$bundle->subfleets()->attach(
|
||||
collect(range(1, 4))
|
||||
->map(fn (int $i): Subfleet => listableSubfleet($airline, 'B'.$b.' Sub '.$i, [$rank]))
|
||||
->pluck('id')
|
||||
->all()
|
||||
);
|
||||
|
||||
$ids = array_merge($ids, Flight::factory()->count($flightsPerBundle)->create([
|
||||
'airline_id' => $airline->id,
|
||||
'bundle_id' => $bundle->id,
|
||||
])->pluck('id')->all());
|
||||
}
|
||||
|
||||
return $ids;
|
||||
};
|
||||
|
||||
$count = function (array $ids) use ($user): int {
|
||||
$queries = 0;
|
||||
DB::listen(function () use (&$queries): void {
|
||||
$queries++;
|
||||
});
|
||||
|
||||
Flight::query()->whereIn('id', $ids)->withAccessibleSubfleets($user)->get();
|
||||
|
||||
// Laravel keeps every listener registered for the life of the
|
||||
// connection, so the second call would otherwise be counted by both.
|
||||
DB::getEventDispatcher()->forget(QueryExecuted::class);
|
||||
|
||||
return $queries;
|
||||
};
|
||||
|
||||
$small = $count($page(1, 2));
|
||||
$large = $count($page(6, 15));
|
||||
|
||||
// Seven statements plus the four `settings` reads `Aircraft::allowedFor`
|
||||
// makes: the flights (with the has-pins probe folded in), the bundles, the
|
||||
// capped bundle subfleets, their aircraft, those aircraft's bids, the
|
||||
// subfleets' fares, and the flights' own pins.
|
||||
//
|
||||
// The accessible-subfleet count that drives truncation logging is a
|
||||
// correlated subselect on the bundles statement, not a statement of its
|
||||
// own, so it is inside this number rather than added to it — dropping the
|
||||
// `withCount` and re-running gives 11 as well.
|
||||
//
|
||||
// The constant is the whole point of the scope: it must not move with page
|
||||
// size or with how many distinct bundles the page touches.
|
||||
expect($small)->toBe(11)
|
||||
->and($large)->toBe(11);
|
||||
});
|
||||
|
||||
test('inheritance resolves through a nested eager load', function (): void {
|
||||
$airline = Airline::factory()->create();
|
||||
$rank = Rank::factory()->create(['name' => 'Line']);
|
||||
@ -494,7 +734,11 @@ test('the flight api response shape is unchanged by inheritance', function (): v
|
||||
foreach ([$inherits->id, $pins->id] as $id) {
|
||||
expect(array_keys($rows[$id]))
|
||||
->not->toContain('bundle')
|
||||
->and(array_keys($rows[$id]))->not->toContain('has_live_pins');
|
||||
->and(array_keys($rows[$id]))->not->toContain('has_live_pins')
|
||||
// The truncation count is loaded onto the bundle, so it leaves by
|
||||
// the same door the bundle does — but it is a separate unset, and
|
||||
// an endpoint that publishes the bundle would carry it out.
|
||||
->and(array_keys($rows[$id]))->not->toContain('subfleets_count');
|
||||
}
|
||||
|
||||
$inheritedKeys = array_keys($rows[$inherits->id]);
|
||||
|
||||
@ -24,7 +24,7 @@ function createInProgressPirep($subtractTime): Pirep
|
||||
}
|
||||
|
||||
test('expired flight not being removed', function (): void {
|
||||
updateSetting('acars.live_time', 0);
|
||||
updateSetting('pireps.tombstone_time', 0);
|
||||
$pirep = createInProgressPirep(2);
|
||||
|
||||
/** @var RemoveExpiredLiveFlights $eventListener */
|
||||
@ -36,7 +36,7 @@ test('expired flight not being removed', function (): void {
|
||||
});
|
||||
|
||||
test('expired flight should not be removed', function (): void {
|
||||
updateSetting('acars.live_time', 3);
|
||||
updateSetting('pireps.tombstone_time', 3);
|
||||
$pirep = createInProgressPirep(2);
|
||||
|
||||
/** @var RemoveExpiredLiveFlights $eventListener */
|
||||
@ -48,7 +48,7 @@ test('expired flight should not be removed', function (): void {
|
||||
});
|
||||
|
||||
test('expired flight should be removed', function (): void {
|
||||
updateSetting('acars.live_time', 3);
|
||||
updateSetting('pireps.tombstone_time', 3);
|
||||
$pirep = createInProgressPirep(4);
|
||||
|
||||
/** @var RemoveExpiredLiveFlights $eventListener */
|
||||
@ -60,7 +60,7 @@ test('expired flight should be removed', function (): void {
|
||||
});
|
||||
|
||||
test('completed flights should not be deleted', function (): void {
|
||||
updateSetting('acars.live_time', 3);
|
||||
updateSetting('pireps.tombstone_time', 3);
|
||||
$pirep = createInProgressPirep(4);
|
||||
|
||||
// Make sure the state is accepted
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Models\Award;
|
||||
use App\Models\News;
|
||||
use App\Models\Pirep;
|
||||
@ -190,8 +190,8 @@ test('a key status change announces once', function (): void {
|
||||
Notification::fake();
|
||||
updateSetting('notifications.discord_pirep_status', true);
|
||||
|
||||
$pirep = Pirep::factory()->create(['status' => PirepStatus::INITIATED]);
|
||||
$pirep->status = PirepStatus::BOARDING;
|
||||
$pirep = Pirep::factory()->create(['status' => PirepPhase::INITIATED]);
|
||||
$pirep->status = PirepPhase::BOARDING;
|
||||
$pirep->save();
|
||||
|
||||
// Proves the status-change announcement fires at all, which is what makes
|
||||
@ -203,8 +203,8 @@ test('a diverted status does not also announce a status change', function (): vo
|
||||
Notification::fake();
|
||||
updateSetting('notifications.discord_pirep_status', true);
|
||||
|
||||
$pirep = Pirep::factory()->create(['status' => PirepStatus::INITIATED]);
|
||||
$pirep->status = PirepStatus::DIVERTED;
|
||||
$pirep = Pirep::factory()->create(['status' => PirepPhase::INITIATED]);
|
||||
$pirep->status = PirepPhase::DIVERTED;
|
||||
$pirep->save();
|
||||
|
||||
// PirepService::handleDiversion() announces a diversion via PirepDiverted,
|
||||
|
||||
30
tests/Feature/Installer/MigrationAutoAdvanceTest.php
Normal file
30
tests/Feature/Installer/MigrationAutoAdvanceTest.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Installer;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* The countdown hangs off the wizard footer that Filament renders for us, so
|
||||
* these assertions are really about the seam with the vendor component: the
|
||||
* Next button has to stay findable from JS, and the Back slot has to have been
|
||||
* taken over by Pause.
|
||||
*/
|
||||
it('renders the auto-advance countdown hooks on the wizard footer', function (): void {
|
||||
Livewire::test(Installer::class)
|
||||
->assertSee('data-installer-next', escape: false)
|
||||
->assertSee("Alpine.store('installerAutoAdvance'", escape: false)
|
||||
->assertSee('$store.installerAutoAdvance.toggle()', escape: false);
|
||||
});
|
||||
|
||||
it('replaces the wizard back button with the pause control', function (): void {
|
||||
Livewire::test(Installer::class)
|
||||
->assertSee('Pause')
|
||||
->assertDontSee(__('filament-schemas::components.wizard.actions.previous_step.label'));
|
||||
});
|
||||
|
||||
// Deliberately not covered here: calling runMigrations(). It shells out to
|
||||
// `php artisan migrate` through StreamedCommandsService, and the subprocess
|
||||
// reads .env rather than phpunit's forced sqlite connection -- so the assertion
|
||||
// would run migrations and DatabaseSeeder against the development database.
|
||||
69
tests/Feature/LegacyImporterClearDatabaseTest.php
Normal file
69
tests/Feature/LegacyImporterClearDatabaseTest.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Fare;
|
||||
use App\Models\FlightBundle;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Subfleet;
|
||||
use App\Services\LegacyImporter\ClearDatabase;
|
||||
use App\Services\LegacyImporterService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* The v5 legacy importer empties the target database before it writes anything
|
||||
* into it. Anything it leaves behind is not merely stale: every id it clears is
|
||||
* a reused auto-increment, so a surviving child row rebinds itself onto
|
||||
* whichever freshly imported parent lands on the old id.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
// BaseImporter builds an ImporterDB from the stored credentials in its
|
||||
// constructor. It does not connect -- ClearDatabase never reads the legacy
|
||||
// database -- but the array has to be there to be read.
|
||||
app(LegacyImporterService::class)->saveCredentials([
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 3306,
|
||||
'name' => 'legacy_v5',
|
||||
'user' => 'phpvms',
|
||||
'pass' => '',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the cleanup takes every subfleet pivot down with the subfleets', function (): void {
|
||||
$subfleet = Subfleet::factory()->create();
|
||||
$bundle = FlightBundle::factory()->create();
|
||||
|
||||
$bundle->subfleets()->attach($subfleet->id);
|
||||
$subfleet->fares()->attach(Fare::factory()->create()->id);
|
||||
$subfleet->ranks()->attach(Rank::factory()->create()->id);
|
||||
|
||||
// No Typerating factory, and the pivot has no foreign keys -- the row is
|
||||
// what matters here, not what it points at.
|
||||
DB::table('typerating_subfleet')->insert([
|
||||
'typerating_id' => 1,
|
||||
'subfleet_id' => $subfleet->id,
|
||||
]);
|
||||
|
||||
$pivots = ['bundle_subfleet', 'subfleet_fare', 'subfleet_rank', 'typerating_subfleet'];
|
||||
$count = fn (): array => collect($pivots)
|
||||
->mapWithKeys(fn (string $table): array => [$table => DB::table($table)->count()])
|
||||
->all();
|
||||
|
||||
expect($count())->toBe(array_fill_keys($pivots, 1));
|
||||
|
||||
new ClearDatabase()->run();
|
||||
|
||||
// A bundle's subfleet defaults cannot outlive the subfleets they name, and
|
||||
// neither can a fare override, a rank grant or a type rating.
|
||||
expect($count())->toBe(array_fill_keys($pivots, 0))
|
||||
->and(Subfleet::count())->toBe(0);
|
||||
|
||||
// The failure mode is silent mis-binding rather than a dangling row: the
|
||||
// truncate above resets the subfleet auto-increment, so a default left
|
||||
// behind would name whichever subfleet the importer writes into that id
|
||||
// next, and rung 2 would hand that one to every flight on the bundle.
|
||||
$imported = Subfleet::factory()->create();
|
||||
|
||||
expect($bundle->subfleets()->count())->toBe(0)
|
||||
->and($imported->bundles()->count())->toBe(0);
|
||||
});
|
||||
193
tests/Feature/LiveMapReadPathTest.php
Normal file
193
tests/Feature/LiveMapReadPathTest.php
Normal file
@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\AcarsType;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepState;
|
||||
use App\Models\Acars;
|
||||
use App\Models\Pirep;
|
||||
use App\Models\PirepPosition;
|
||||
use App\Models\User;
|
||||
use App\Services\GeoService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** getLine() returns an empty FeatureCollection below two points. */
|
||||
function trailCoordinates(array $geo): array
|
||||
{
|
||||
$features = $geo['line']->getFeatures();
|
||||
|
||||
return $features === [] ? [] : $features[0]->getGeometry()->getCoordinates();
|
||||
}
|
||||
|
||||
function flightOnLiveMap(array $pirepAttrs = [], array $positionAttrs = []): Pirep
|
||||
{
|
||||
$pirep = Pirep::factory()->create(array_merge([
|
||||
'state' => PirepState::IN_PROGRESS,
|
||||
'status' => PirepPhase::ENROUTE,
|
||||
], $pirepAttrs));
|
||||
|
||||
PirepPosition::factory()->create(array_merge([
|
||||
'pirep_id' => $pirep->id,
|
||||
'user_id' => $pirep->user_id,
|
||||
], $positionAttrs));
|
||||
|
||||
return $pirep;
|
||||
}
|
||||
|
||||
test('a flight is on the map if and only if it has a position row', function (): void {
|
||||
$onMap = flightOnLiveMap();
|
||||
|
||||
// In progress, but no position row: not on the map.
|
||||
$offMap = Pirep::factory()->create(['state' => PirepState::IN_PROGRESS]);
|
||||
|
||||
// Finished but not yet evicted. The old query filtered this out.
|
||||
$completed = flightOnLiveMap(['state' => PirepState::PENDING]);
|
||||
|
||||
$ids = collect(test()->get('/api/acars')->json('data'))->pluck('id');
|
||||
|
||||
expect($ids)->toContain($onMap->id)
|
||||
->and($ids)->toContain($completed->id)
|
||||
->and($ids)->not->toContain($offMap->id);
|
||||
});
|
||||
|
||||
test('the response still carries the nested position object', function (): void {
|
||||
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 41.5, 'lon' => -87.25, 'heading' => 270]);
|
||||
|
||||
$flight = collect(test()->get('/api/acars')->json('data'))
|
||||
->firstWhere('id', $pirep->id);
|
||||
|
||||
expect($flight)->toHaveKey('position')
|
||||
->and($flight['position']['lat'])->toEqual(41.5)
|
||||
->and($flight['position']['lon'])->toEqual(-87.25)
|
||||
->and($flight['position']['heading'])->toBe(270)
|
||||
->and($flight['position']['distance'])->toHaveKeys(['m', 'km', 'mi', 'nmi']);
|
||||
});
|
||||
|
||||
test('the GeoJSON endpoint draws a point per flight on the map', function (): void {
|
||||
$pirep = flightOnLiveMap(positionAttrs: [
|
||||
'lat' => 12.5, 'lon' => -30.25, 'heading' => 90, 'altitude_msl' => 33000,
|
||||
]);
|
||||
|
||||
$features = test()->get('/api/acars/geojson')->json('data.features');
|
||||
$feature = collect($features)->firstWhere('properties.pirep_id', $pirep->id);
|
||||
|
||||
expect($feature['geometry']['coordinates'])->toEqual([-30.25, 12.5, 33000])
|
||||
->and($feature['properties']['heading'])->toBe(90);
|
||||
});
|
||||
|
||||
test('a flight with thousands of breadcrumbs costs no extra queries', function (): void {
|
||||
$busy = flightOnLiveMap();
|
||||
|
||||
Acars::factory()->count(50)->create([
|
||||
'pirep_id' => $busy->id,
|
||||
'type' => AcarsType::FLIGHT_PATH,
|
||||
]);
|
||||
|
||||
flightOnLiveMap();
|
||||
flightOnLiveMap();
|
||||
|
||||
$queries = [];
|
||||
DB::listen(function ($query) use (&$queries): void {
|
||||
$queries[] = $query->sql;
|
||||
});
|
||||
|
||||
test()->get('/api/acars')->assertOk();
|
||||
|
||||
// No latest-row lookup against `acars`, so cost doesn't grow with track length.
|
||||
$acarsQueries = array_filter($queries, fn (string $sql): bool => str_contains($sql, '"acars"') || str_contains($sql, '`acars`'));
|
||||
|
||||
expect($acarsQueries)->toBe([]);
|
||||
});
|
||||
|
||||
test('the trail ends at the live position', function (): void {
|
||||
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 40.0, 'lon' => -80.0]);
|
||||
|
||||
foreach ([[33.0, -90.0], [35.0, -88.0], [37.0, -85.0]] as $i => [$lat, $lon]) {
|
||||
Acars::factory()->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'type' => AcarsType::FLIGHT_PATH,
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
'created_at' => Carbon::now('UTC')->subMinutes(10 - $i),
|
||||
]);
|
||||
}
|
||||
|
||||
$geo = app(GeoService::class)->getFeatureFromAcars($pirep->fresh());
|
||||
$coords = trailCoordinates($geo);
|
||||
|
||||
// Three breadcrumbs plus the live position, in order.
|
||||
expect($coords)->toHaveCount(4)
|
||||
->and(end($coords))->toBe([-80.0, 40.0])
|
||||
->and($geo['position'])->toBe(['lat' => 40.0, 'lon' => -80.0]);
|
||||
});
|
||||
|
||||
test('the trail renders with no breadcrumbs yet', function (): void {
|
||||
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 30.0, 'lon' => -97.0]);
|
||||
|
||||
$geo = app(GeoService::class)->getFeatureFromAcars($pirep->fresh());
|
||||
|
||||
expect($geo['points']->getFeatures())->toHaveCount(1)
|
||||
->and($geo['position'])->toBe(['lat' => 30.0, 'lon' => -97.0]);
|
||||
});
|
||||
|
||||
test('appending the live position invents no intermediate points', function (): void {
|
||||
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 60.0, 'lon' => -10.0]);
|
||||
|
||||
// Wide gaps. Nothing may be interpolated to smooth them.
|
||||
foreach ([[10.0, -100.0], [30.0, -60.0]] as $i => [$lat, $lon]) {
|
||||
Acars::factory()->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'type' => AcarsType::FLIGHT_PATH,
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
'created_at' => Carbon::now('UTC')->subMinutes(10 - $i),
|
||||
]);
|
||||
}
|
||||
|
||||
$coords = trailCoordinates(app(GeoService::class)->getFeatureFromAcars($pirep->fresh()));
|
||||
|
||||
expect($coords)->toBe([[-100.0, 10.0], [-60.0, 30.0], [-10.0, 60.0]]);
|
||||
});
|
||||
|
||||
test('the trail gains no duplicate point when the position matches the last breadcrumb', function (): void {
|
||||
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 37.0, 'lon' => -85.0]);
|
||||
|
||||
Acars::factory()->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'type' => AcarsType::FLIGHT_PATH,
|
||||
'lat' => 37.0,
|
||||
'lon' => -85.0,
|
||||
'created_at' => Carbon::now('UTC'),
|
||||
]);
|
||||
|
||||
$geo = app(GeoService::class)->getFeatureFromAcars($pirep->fresh());
|
||||
|
||||
// The live position sits on the one breadcrumb, so the trail stays a single point.
|
||||
expect($geo['points']->getFeatures())->toHaveCount(1)
|
||||
->and(trailCoordinates($geo))->toBe([]);
|
||||
});
|
||||
|
||||
test('a single PIREP flown route still comes from acars', function (): void {
|
||||
$pirep = flightOnLiveMap(positionAttrs: ['lat' => 40.0, 'lon' => -80.0]);
|
||||
|
||||
foreach ([[33.0, -90.0], [35.0, -88.0]] as $i => [$lat, $lon]) {
|
||||
Acars::factory()->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'type' => AcarsType::FLIGHT_PATH,
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
'sim_time' => Carbon::now('UTC')->subMinutes(10 - $i)->toIso8601String(),
|
||||
'created_at' => Carbon::now('UTC')->subMinutes(10 - $i),
|
||||
]);
|
||||
}
|
||||
|
||||
apiAs(User::find($pirep->user_id));
|
||||
$route = test()->get('/api/pireps/'.$pirep->id.'/acars/position')->assertOk()->json('data');
|
||||
|
||||
// Same rows, same order, and the live position is not among them.
|
||||
expect($route)->toHaveCount(2)
|
||||
->and($route[0]['lat'])->toEqual(33.0)
|
||||
->and($route[1]['lat'])->toEqual(35.0);
|
||||
});
|
||||
196
tests/Feature/LiveMapSettingsMigrationTest.php
Normal file
196
tests/Feature/LiveMapSettingsMigrationTest.php
Normal file
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Setting;
|
||||
use Database\Seeders\SettingsSeeder;
|
||||
|
||||
function liveMapSettingsMigration(): object
|
||||
{
|
||||
return require base_path('database/migrations_data/2026_07_27_000001_live_map_settings.php');
|
||||
}
|
||||
|
||||
function findSetting(string $key): ?Setting
|
||||
{
|
||||
return Setting::where('id', Setting::formatKey($key))->first();
|
||||
}
|
||||
|
||||
/** A pre-change `acars` group setting, as an install carries it. */
|
||||
function putAcarsSetting(string $key, string $value, string $name, string $type = 'int'): void
|
||||
{
|
||||
$model = new Setting([
|
||||
'key' => $key,
|
||||
'name' => $name,
|
||||
'value' => $value,
|
||||
'group' => 'acars',
|
||||
'type' => $type,
|
||||
'options' => '',
|
||||
'description' => 'the old description',
|
||||
]);
|
||||
$model->id = Setting::formatKey($key);
|
||||
$model->default = $value;
|
||||
$model->offset = 7;
|
||||
$model->order = 7;
|
||||
$model->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* What a real upgrade presents: the seeder has already run, so the new keys exist
|
||||
* at their defaults with the old `acars` keys still alongside them.
|
||||
*/
|
||||
function seedPreUpgradeState(string $liveTime = '12'): void
|
||||
{
|
||||
putAcarsSetting('acars.live_time', $liveTime, 'Live Time');
|
||||
putAcarsSetting('acars.center_coords', '30.1945,-97.6699', 'Center Coords', 'text');
|
||||
putAcarsSetting('acars.default_zoom', '5', 'Default Zoom');
|
||||
putAcarsSetting('acars.update_interval', '60', 'Refresh Interval');
|
||||
}
|
||||
|
||||
test('a customised live time survives the move to the tombstone setting', function (): void {
|
||||
seedPreUpgradeState(liveTime: '24');
|
||||
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
$tombstone = findSetting('pireps.tombstone_time');
|
||||
|
||||
// 24 hours, still meaning 24 hours: the unit is not converted.
|
||||
expect($tombstone)->not->toBeNull()
|
||||
->and($tombstone->value)->toBe('24')
|
||||
->and($tombstone->group)->toBe('pireps')
|
||||
->and($tombstone->default)->toBe('12')
|
||||
->and(findSetting('acars.live_time'))->toBeNull();
|
||||
});
|
||||
|
||||
test('an untouched default lands on the default', function (): void {
|
||||
seedPreUpgradeState(liveTime: '12');
|
||||
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
expect(findSetting('pireps.tombstone_time')->value)->toBe('12');
|
||||
});
|
||||
|
||||
test('the display settings are regrouped with their values intact', function (): void {
|
||||
seedPreUpgradeState();
|
||||
Setting::where('id', Setting::formatKey('acars.center_coords'))->update(['value' => '51.4700,-0.4543']);
|
||||
Setting::where('id', Setting::formatKey('acars.default_zoom'))->update(['value' => '9']);
|
||||
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
expect(findSetting('livemap.center_coords')->value)->toBe('51.4700,-0.4543')
|
||||
->and(findSetting('livemap.center_coords')->group)->toBe('livemap')
|
||||
->and(findSetting('livemap.default_zoom')->value)->toBe('9')
|
||||
->and(findSetting('livemap.update_interval')->value)->toBe('60');
|
||||
|
||||
expect(findSetting('acars.center_coords'))->toBeNull()
|
||||
->and(findSetting('acars.default_zoom'))->toBeNull()
|
||||
->and(findSetting('acars.update_interval'))->toBeNull();
|
||||
});
|
||||
|
||||
test('no setting is left in the acars group', function (): void {
|
||||
seedPreUpgradeState();
|
||||
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
expect(Setting::where('group', 'acars')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('the two new timers are left to the seeder', function (): void {
|
||||
seedPreUpgradeState();
|
||||
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
// No old key maps onto either, so the migration must not touch them.
|
||||
expect(findSetting('livemap.live_time')->value)->toBe('30')
|
||||
->and(findSetting('livemap.live_time')->group)->toBe('livemap')
|
||||
->and(findSetting('livemap.idle_time')->value)->toBe('60')
|
||||
->and(findSetting('livemap.idle_time')->group)->toBe('livemap');
|
||||
});
|
||||
|
||||
test('a re-run leaves a value the admin has since changed alone', function (): void {
|
||||
seedPreUpgradeState(liveTime: '24');
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
Setting::where('id', Setting::formatKey('pireps.tombstone_time'))->update(['value' => '6']);
|
||||
|
||||
// The source is gone, so there is nothing to carry and nothing to clobber.
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
expect(findSetting('pireps.tombstone_time')->value)->toBe('6');
|
||||
});
|
||||
|
||||
test('the migration is a no-op on an install that has already been through it', function (): void {
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
expect(findSetting('pireps.tombstone_time')->value)->toBe('12')
|
||||
->and(findSetting('livemap.center_coords')->value)->toBe('30.1945,-97.6699')
|
||||
->and(Setting::where('group', 'acars')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('reversing restores the previous keys, names and descriptions', function (): void {
|
||||
seedPreUpgradeState(liveTime: '24');
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
liveMapSettingsMigration()->down();
|
||||
|
||||
$liveTime = findSetting('acars.live_time');
|
||||
|
||||
expect($liveTime)->not->toBeNull()
|
||||
->and($liveTime->value)->toBe('24')
|
||||
->and($liveTime->name)->toBe('Live Time')
|
||||
->and($liveTime->group)->toBe('acars')
|
||||
->and($liveTime->description)->toContain('Age of flights to show on the map in hours');
|
||||
|
||||
expect(findSetting('acars.center_coords')->group)->toBe('acars')
|
||||
->and(findSetting('acars.default_zoom')->group)->toBe('acars')
|
||||
->and(findSetting('acars.update_interval')->group)->toBe('acars');
|
||||
|
||||
expect(findSetting('pireps.tombstone_time'))->toBeNull();
|
||||
});
|
||||
|
||||
test('reversing removes the settings that did not exist before', function (): void {
|
||||
seedPreUpgradeState();
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
liveMapSettingsMigration()->down();
|
||||
|
||||
// No pre-change counterpart to rename back to.
|
||||
expect(findSetting('livemap.live_time'))->toBeNull()
|
||||
->and(findSetting('livemap.idle_time'))->toBeNull();
|
||||
});
|
||||
|
||||
test('a fresh install and an upgraded install agree on key, group and default', function (): void {
|
||||
$keys = [
|
||||
'pireps.tombstone_time',
|
||||
'livemap.live_time',
|
||||
'livemap.idle_time',
|
||||
'livemap.center_coords',
|
||||
'livemap.default_zoom',
|
||||
'livemap.update_interval',
|
||||
];
|
||||
|
||||
$snapshot = fn (): array => collect($keys)
|
||||
->mapWithKeys(fn (string $key): array => [
|
||||
$key => [
|
||||
'key' => findSetting($key)?->key,
|
||||
'group' => findSetting($key)?->group,
|
||||
'default' => findSetting($key)?->default,
|
||||
],
|
||||
])
|
||||
->all();
|
||||
|
||||
// Pest's beforeEach has already seeded: this is the fresh install.
|
||||
$fresh = $snapshot();
|
||||
|
||||
// Wind back to a pre-change install.
|
||||
Setting::query()->whereIn('id', array_map(Setting::formatKey(...), $keys))->delete();
|
||||
seedPreUpgradeState(liveTime: '24');
|
||||
|
||||
// Replay the upgrade in Updater's order: seeders, then data migrations.
|
||||
new SettingsSeeder()->run();
|
||||
liveMapSettingsMigration()->up();
|
||||
|
||||
expect($snapshot())->toBe($fresh);
|
||||
|
||||
// The only thing an upgrade carries that a fresh install does not.
|
||||
expect(findSetting('pireps.tombstone_time')->value)->toBe('24');
|
||||
});
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
use App\Enums\AcarsType;
|
||||
use App\Enums\PirepFieldSource;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Enums\UserState;
|
||||
use App\Models\Acars;
|
||||
use App\Models\Aircraft;
|
||||
@ -753,7 +753,7 @@ test('diversion handler reuses matching reposition flight and attaches subfleet'
|
||||
'airline_id' => $airline->id,
|
||||
'flight_number' => $flight->flight_number,
|
||||
'callsign' => $flight->callsign,
|
||||
'route_code' => PirepStatus::DIVERTED,
|
||||
'route_code' => PirepPhase::DIVERTED,
|
||||
'dpt_airport_id' => $diversionAirport->id,
|
||||
'arr_airport_id' => $originalArrivalAirport->id,
|
||||
'user_id' => $user->id,
|
||||
@ -783,7 +783,7 @@ test('diversion handler reuses matching reposition flight and attaches subfleet'
|
||||
'airline_id' => $airline->id,
|
||||
'flight_number' => $flight->flight_number,
|
||||
'callsign' => $flight->callsign,
|
||||
'route_code' => PirepStatus::DIVERTED,
|
||||
'route_code' => PirepPhase::DIVERTED,
|
||||
'dpt_airport_id' => $diversionAirport->id,
|
||||
'arr_airport_id' => $originalArrivalAirport->id,
|
||||
'user_id' => $user->id,
|
||||
|
||||
50
tests/Feature/PirepPhaseEnumTest.php
Normal file
50
tests/Feature/PirepPhaseEnumTest.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Http\Resources\PirepResource;
|
||||
use App\Models\Pirep;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
test('the old name resolves to the new enum rather than a parallel one', function (): void {
|
||||
// ::class is resolved by the compiler, so reflection is what follows the alias.
|
||||
expect(new ReflectionClass(PirepStatus::class)->getName())->toBe(PirepPhase::class);
|
||||
|
||||
// Not toEqual: a copied enum would compare equal and still be a different type.
|
||||
expect(PirepStatus::TAXI)->toBe(PirepPhase::TAXI)
|
||||
->and(PirepStatus::cases())->toBe(PirepPhase::cases());
|
||||
});
|
||||
|
||||
test('type checks against the old name pass for cases of the new enum', function (): void {
|
||||
$phase = PirepPhase::ENROUTE;
|
||||
|
||||
expect($phase)->toBeInstanceOf(PirepStatus::class)
|
||||
->and(PirepStatus::ENROUTE)->toBeInstanceOf(PirepPhase::class);
|
||||
|
||||
// Keeps addon signatures typed against the old name working.
|
||||
$takesOldName = fn (PirepStatus $p): string => $p->value;
|
||||
expect($takesOldName(PirepPhase::LANDED))->toBe('LAN');
|
||||
});
|
||||
|
||||
test('phase values stored before the rename read back to the same cases', function (): void {
|
||||
$pirep = Pirep::factory()->create();
|
||||
|
||||
// The raw code as a pre-rename install holds it, bypassing the cast.
|
||||
DB::table('pireps')->where('id', $pirep->id)->update(['status' => 'ENR']);
|
||||
|
||||
$reloaded = Pirep::find($pirep->id);
|
||||
|
||||
expect($reloaded->status)->toBe(PirepPhase::ENROUTE)
|
||||
->and($reloaded->status)->toBe(PirepStatus::ENROUTE)
|
||||
->and($reloaded->status->value)->toBe('ENR');
|
||||
});
|
||||
|
||||
test('the API still publishes the value under phase', function (): void {
|
||||
$pirep = Pirep::factory()->create(['status' => PirepPhase::ENROUTE]);
|
||||
|
||||
$res = PirepResource::make($pirep)->toArray(request());
|
||||
|
||||
expect($res)->toHaveKey('phase')
|
||||
->and($res['phase'])->toBe(PirepPhase::ENROUTE)
|
||||
->and($res)->not->toHaveKey('status_enum');
|
||||
});
|
||||
167
tests/Feature/PirepPositionExpirationTest.php
Normal file
167
tests/Feature/PirepPositionExpirationTest.php
Normal file
@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Cron\FiveMinute\PirepPositionExpiration;
|
||||
use App\Cron\Hourly\RemoveExpiredLiveFlights;
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepState;
|
||||
use App\Events\CronFiveMinute;
|
||||
use App\Events\CronHourly;
|
||||
use App\Models\Pirep;
|
||||
use App\Models\PirepPosition;
|
||||
use App\Services\PirepService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function runExpiration(): void
|
||||
{
|
||||
app(PirepPositionExpiration::class)->handle(new CronFiveMinute());
|
||||
}
|
||||
|
||||
/**
|
||||
* A flight on the map. `$moved` false means it has sat at the gate since prefile,
|
||||
* which the row records as updated_at still equalling created_at.
|
||||
*/
|
||||
function flightOnMap(int $reportedAgo, array $pirepAttrs = [], bool $moved = true): Pirep
|
||||
{
|
||||
$pirep = Pirep::factory()->create(array_merge([
|
||||
'state' => PirepState::IN_PROGRESS,
|
||||
'status' => PirepPhase::ENROUTE,
|
||||
], $pirepAttrs));
|
||||
|
||||
PirepPosition::factory()->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'user_id' => $pirep->user_id,
|
||||
]);
|
||||
|
||||
$last = Carbon::now('UTC')->subMinutes($reportedAgo);
|
||||
|
||||
DB::table('pirep_positions')->where('pirep_id', $pirep->id)->update([
|
||||
'created_at' => $moved ? $last->copy()->subDay() : $last,
|
||||
'updated_at' => $last,
|
||||
]);
|
||||
|
||||
return $pirep;
|
||||
}
|
||||
|
||||
function onMap(Pirep $pirep): bool
|
||||
{
|
||||
return PirepPosition::where('pirep_id', $pirep->id)->exists();
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
updateSetting('pireps.tombstone_time', 12);
|
||||
updateSetting('livemap.live_time', 30);
|
||||
updateSetting('livemap.idle_time', 60);
|
||||
});
|
||||
|
||||
test('a completed flight past its window leaves the map', function (): void {
|
||||
$old = flightOnMap(reportedAgo: 45, pirepAttrs: ['state' => PirepState::PENDING]);
|
||||
$fresh = flightOnMap(reportedAgo: 15, pirepAttrs: ['state' => PirepState::PENDING]);
|
||||
|
||||
runExpiration();
|
||||
|
||||
expect(onMap($old))->toBeFalse()
|
||||
->and(onMap($fresh))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a paused flight past its window leaves the map but survives as a PIREP', function (): void {
|
||||
$pirep = flightOnMap(reportedAgo: 90, pirepAttrs: ['status' => PirepPhase::PAUSED]);
|
||||
|
||||
runExpiration();
|
||||
|
||||
// A paused PIREP is paused deliberately: eviction must not touch the record.
|
||||
expect(onMap($pirep))->toBeFalse()
|
||||
->and(Pirep::find($pirep->id))->not->toBeNull()
|
||||
->and(Pirep::find($pirep->id)->state)->toBe(PirepState::IN_PROGRESS);
|
||||
});
|
||||
|
||||
test('a paused flight within its window stays on the map', function (): void {
|
||||
$pirep = flightOnMap(reportedAgo: 30, pirepAttrs: ['status' => PirepPhase::PAUSED]);
|
||||
|
||||
runExpiration();
|
||||
|
||||
expect(onMap($pirep))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a paused flight is not reaped on the tombstone clock', function (): void {
|
||||
// idle_time governs map membership only, never reaping.
|
||||
$pirep = flightOnMap(reportedAgo: 60 * 20, pirepAttrs: ['status' => PirepPhase::PAUSED]);
|
||||
|
||||
runExpiration();
|
||||
app(RemoveExpiredLiveFlights::class)->handle(new CronHourly());
|
||||
|
||||
expect(onMap($pirep))->toBeFalse()
|
||||
->and(Pirep::find($pirep->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a prefiled flight that never departs is evicted on the stationary timer', function (): void {
|
||||
$stale = flightOnMap(reportedAgo: 90, pirepAttrs: ['status' => PirepPhase::INITIATED], moved: false);
|
||||
$recent = flightOnMap(reportedAgo: 30, pirepAttrs: ['status' => PirepPhase::INITIATED], moved: false);
|
||||
|
||||
runExpiration();
|
||||
|
||||
// Same timer as a paused flight: both are present and not moving.
|
||||
expect(onMap($stale))->toBeFalse()
|
||||
->and(onMap($recent))->toBeTrue();
|
||||
});
|
||||
|
||||
test('phase and state disagreeing resolves on state', function (): void {
|
||||
// Filed, but the last reported phase is still an arrived aircraft.
|
||||
$pirep = flightOnMap(reportedAgo: 45, pirepAttrs: [
|
||||
'state' => PirepState::PENDING,
|
||||
'status' => PirepPhase::ARRIVED,
|
||||
]);
|
||||
|
||||
runExpiration();
|
||||
|
||||
expect(onMap($pirep))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a completed flight is clocked from its last position, not its filing time', function (): void {
|
||||
// Landed 12:00, filed 15:00. submitted_at would draw it for 3.5 more hours.
|
||||
Carbon::setTestNow(Carbon::parse('2026-07-27 15:05:00', 'UTC'));
|
||||
|
||||
$pirep = Pirep::factory()->create([
|
||||
'state' => PirepState::PENDING,
|
||||
'status' => PirepPhase::ARRIVED,
|
||||
'submitted_at' => Carbon::parse('2026-07-27 15:00:00', 'UTC'),
|
||||
]);
|
||||
|
||||
PirepPosition::factory()->create(['pirep_id' => $pirep->id, 'user_id' => $pirep->user_id]);
|
||||
|
||||
DB::table('pirep_positions')->where('pirep_id', $pirep->id)->update([
|
||||
'created_at' => Carbon::parse('2026-07-27 10:00:00', 'UTC'),
|
||||
'updated_at' => Carbon::parse('2026-07-27 12:00:00', 'UTC'),
|
||||
]);
|
||||
|
||||
runExpiration();
|
||||
|
||||
// Gone on the landing clock, not the filing one.
|
||||
expect(onMap($pirep))->toBeFalse();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('zero disables a timer rather than expiring everything', function (): void {
|
||||
updateSetting('livemap.live_time', 0);
|
||||
updateSetting('livemap.idle_time', 0);
|
||||
|
||||
$completed = flightOnMap(reportedAgo: 60 * 24, pirepAttrs: ['state' => PirepState::PENDING]);
|
||||
$paused = flightOnMap(reportedAgo: 60 * 24, pirepAttrs: ['status' => PirepPhase::PAUSED]);
|
||||
|
||||
runExpiration();
|
||||
|
||||
expect(onMap($completed))->toBeTrue()
|
||||
->and(onMap($paused))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cancelling takes a flight off the map before the request completes', function (): void {
|
||||
$pirep = flightOnMap(reportedAgo: 1);
|
||||
|
||||
app(PirepService::class)->cancel($pirep);
|
||||
|
||||
// No expiration run in between - the cancel path is synchronous.
|
||||
expect(onMap($pirep))->toBeFalse();
|
||||
});
|
||||
72
tests/Feature/PirepPositionTest.php
Normal file
72
tests/Feature/PirepPositionTest.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\AcarsType;
|
||||
use App\Models\Acars;
|
||||
use App\Models\Pirep;
|
||||
use App\Models\PirepPosition;
|
||||
use App\Support\Units\Distance;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
test('distance keeps its fractional part', function (): void {
|
||||
$pirep = Pirep::factory()->create();
|
||||
|
||||
PirepPosition::factory()->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'user_id' => $pirep->user_id,
|
||||
'distance' => 1234.56,
|
||||
]);
|
||||
|
||||
// Past the cast: `acars`.`distance` was an int and would have stored 1234.
|
||||
$raw = DB::table('pirep_positions')->where('pirep_id', $pirep->id)->value('distance');
|
||||
|
||||
expect(round((float) $raw, 2))->toBe(1234.56);
|
||||
});
|
||||
|
||||
test('vertical speed stores negatives', function (): void {
|
||||
$pirep = Pirep::factory()->create();
|
||||
|
||||
$position = PirepPosition::factory()->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'user_id' => $pirep->user_id,
|
||||
'vs' => -1800,
|
||||
]);
|
||||
|
||||
expect($position->fresh()->vs)->toBe(-1800.0);
|
||||
});
|
||||
|
||||
test('display units apply as they do for acars', function (): void {
|
||||
updateSetting('units.distance', 'km');
|
||||
|
||||
$pirep = Pirep::factory()->create();
|
||||
|
||||
$position = PirepPosition::factory()->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'user_id' => $pirep->user_id,
|
||||
'distance' => 100,
|
||||
]);
|
||||
|
||||
$acars = Acars::factory()->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'type' => AcarsType::FLIGHT_PATH,
|
||||
'distance' => 100,
|
||||
]);
|
||||
|
||||
// Units come from the cast, not the column name.
|
||||
expect($position->fresh()->distance)->toBeInstanceOf(Distance::class)
|
||||
->and($position->fresh()->distance->local(2))
|
||||
->toBe($acars->fresh()->distance->local(2));
|
||||
});
|
||||
|
||||
test('every telemetry column is not null', function (): void {
|
||||
// Seeded-to-zero only holds if the schema enforces it.
|
||||
$nullable = collect(Schema::getColumns('pirep_positions'))
|
||||
->reject(fn (array $column): bool => in_array($column['name'], ['created_at', 'updated_at'], true))
|
||||
->filter(fn (array $column): bool => $column['nullable'])
|
||||
->pluck('name')
|
||||
->all();
|
||||
|
||||
expect($nullable)->toBe([]);
|
||||
});
|
||||
252
tests/Feature/PirepPositionWriteTest.php
Normal file
252
tests/Feature/PirepPositionWriteTest.php
Normal file
@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\PirepPhase;
|
||||
use App\Enums\PirepState;
|
||||
use App\Events\PirepUpdated;
|
||||
use App\Models\Acars;
|
||||
use App\Models\Airline;
|
||||
use App\Models\Airport;
|
||||
use App\Models\Pirep;
|
||||
use App\Models\PirepPosition;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Subfleet;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
/**
|
||||
* Prefile a flight through the API, as an ACARS client does.
|
||||
*/
|
||||
function prefileFlight(): array
|
||||
{
|
||||
$subfleet = Subfleet::factory()->hasAircraft(1)->create();
|
||||
$rank = Rank::factory()->hasAttached($subfleet)->create();
|
||||
$user = User::factory()->create(['rank_id' => $rank->id]);
|
||||
|
||||
$dpt = Airport::factory()->create();
|
||||
$arr = Airport::factory()->create();
|
||||
$airline = Airline::factory()->create();
|
||||
$aircraft = $subfleet->aircraft->first();
|
||||
$aircraft->update(['airport_id' => $dpt->id]);
|
||||
|
||||
apiAs($user);
|
||||
|
||||
$response = test()->post('/api/pireps/prefile', [
|
||||
'airline_id' => $airline->id,
|
||||
'aircraft_id' => $aircraft->id,
|
||||
'flight_number' => '1234',
|
||||
'source_name' => 'PirepPositionWriteTest',
|
||||
'dpt_airport_id' => $dpt->icao,
|
||||
'arr_airport_id' => $arr->icao,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
return [Pirep::find($response->json('data.id')), $user];
|
||||
}
|
||||
|
||||
function positionRow(Pirep $pirep): ?PirepPosition
|
||||
{
|
||||
return PirepPosition::find($pirep->id);
|
||||
}
|
||||
|
||||
/** `created_at` is collection time, which separates arrival from collection order. */
|
||||
function postPositions(Pirep $pirep, array $positions): void
|
||||
{
|
||||
test()->post('/api/pireps/'.$pirep->id.'/acars/position', ['positions' => $positions])
|
||||
->assertStatus(200);
|
||||
}
|
||||
|
||||
function point(Carbon $collectedAt, float $lat, float $lon, array $extra = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
'created_at' => $collectedAt->toIso8601String(),
|
||||
'sim_time' => $collectedAt->toIso8601String(),
|
||||
], $extra);
|
||||
}
|
||||
|
||||
test('prefiling puts the flight on the map at its departure airport', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
$airport = Airport::find($pirep->dpt_airport_id);
|
||||
|
||||
$position = positionRow($pirep);
|
||||
|
||||
expect($position)->not->toBeNull()
|
||||
->and((float) $position->lat)->toBe((float) $airport->lat)
|
||||
->and((float) $position->lon)->toBe((float) $airport->lon)
|
||||
->and($position->phase)->toBe(PirepPhase::SCHEDULED)
|
||||
->and($position->user_id)->toBe($pirep->user_id);
|
||||
});
|
||||
|
||||
test('telemetry not yet reported is zero rather than null', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
|
||||
$position = positionRow($pirep);
|
||||
|
||||
expect($position->gs)->toBe(0)
|
||||
->and($position->ias)->toBe(0)
|
||||
->and($position->vs)->toBe(0.0)
|
||||
->and($position->heading)->toBe(0)
|
||||
->and($position->flight_time)->toBe(0)
|
||||
->and($position->altitude_agl)->toBe(0.0)
|
||||
->and($position->altitude_msl)->toBe(0.0)
|
||||
->and($position->distance->internal())->toBe(0.0);
|
||||
});
|
||||
|
||||
test('the first position batch replaces the seeded coordinates', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
$seeded = positionRow($pirep);
|
||||
|
||||
postPositions($pirep, [point(Carbon::now('UTC'), 41.5, -87.25)]);
|
||||
|
||||
$position = positionRow($pirep);
|
||||
|
||||
expect((float) $position->lat)->toBe(41.5)
|
||||
->and((float) $position->lon)->toBe(-87.25)
|
||||
->and((float) $position->lat)->not->toBe((float) $seeded->lat);
|
||||
});
|
||||
|
||||
test('the position row reflects the newest point in a batch', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
$at = Carbon::now('UTC');
|
||||
|
||||
postPositions($pirep, [
|
||||
point($at->copy()->subMinutes(2), 10.0, 10.0),
|
||||
point($at->copy(), 12.0, 12.0),
|
||||
point($at->copy()->subMinute(), 11.0, 11.0),
|
||||
]);
|
||||
|
||||
expect((float) positionRow($pirep)->lat)->toBe(12.0);
|
||||
});
|
||||
|
||||
test('an out-of-order batch does not move the position row backwards', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
$now = Carbon::now('UTC');
|
||||
|
||||
postPositions($pirep, [point($now->copy(), 50.0, 10.0)]);
|
||||
|
||||
// Collected earlier, arriving later: a replay or a catching-up client.
|
||||
postPositions($pirep, [point($now->copy()->subMinutes(10), 20.0, 20.0)]);
|
||||
|
||||
expect((float) positionRow($pirep)->lat)->toBe(50.0)
|
||||
->and((float) positionRow($pirep)->lon)->toBe(10.0);
|
||||
|
||||
// The breadcrumb is still recorded; only the marker refuses to move.
|
||||
expect(Acars::where('pirep_id', $pirep->id)->flightPath()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('repeated batches for one PIREP leave exactly one row', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
$at = Carbon::now('UTC');
|
||||
|
||||
for ($i = 1; $i <= 50; $i++) {
|
||||
postPositions($pirep, [point($at->copy()->addSeconds($i), $i, $i)]);
|
||||
}
|
||||
|
||||
expect(DB::table('pirep_positions')->where('pirep_id', $pirep->id)->count())->toBe(1)
|
||||
->and((float) positionRow($pirep)->lat)->toBe(50.0)
|
||||
->and(Acars::where('pirep_id', $pirep->id)->flightPath()->count())->toBe(50);
|
||||
});
|
||||
|
||||
test('a batch for a filed PIREP still moves the marker', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
$at = Carbon::now('UTC');
|
||||
|
||||
postPositions($pirep, [point($at->copy(), 33.0, 33.0)]);
|
||||
|
||||
// Filing moves it to PENDING while the client may still be sending.
|
||||
$pirep->state = PirepState::PENDING;
|
||||
$pirep->save();
|
||||
|
||||
postPositions($pirep, [point($at->copy()->addMinute(), 34.0, 34.0)]);
|
||||
|
||||
expect((float) positionRow($pirep)->lat)->toBe(34.0);
|
||||
});
|
||||
|
||||
test('a batch for an accepted PIREP writes acars but no position', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
positionRow($pirep)->delete();
|
||||
|
||||
$pirep->state = PirepState::ACCEPTED;
|
||||
$pirep->save();
|
||||
|
||||
postPositions($pirep, [point(Carbon::now('UTC'), 33.0, 33.0)]);
|
||||
|
||||
// The breadcrumb is still written: that contract is unchanged.
|
||||
expect(Acars::where('pirep_id', $pirep->id)->flightPath()->count())->toBe(1)
|
||||
->and(positionRow($pirep))->toBeNull();
|
||||
});
|
||||
|
||||
test('a late batch cannot return an evicted flight to the map', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
$at = Carbon::now('UTC');
|
||||
|
||||
postPositions($pirep, [point($at->copy()->subHour(), 12.0, 12.0)]);
|
||||
|
||||
// Already reviewed, and its row already evicted.
|
||||
$pirep->state = PirepState::REJECTED;
|
||||
$pirep->save();
|
||||
|
||||
positionRow($pirep)->delete();
|
||||
|
||||
postPositions($pirep, [point($at->copy(), 13.0, 13.0)]);
|
||||
|
||||
expect(positionRow($pirep))->toBeNull();
|
||||
});
|
||||
|
||||
test('the position row updated_at moves on batches but not on an admin edit', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
|
||||
postPositions($pirep, [point(Carbon::now('UTC'), 5.0, 5.0)]);
|
||||
$afterBatch = positionRow($pirep)->updated_at;
|
||||
|
||||
// Time has to pass, or "unchanged" proves nothing.
|
||||
Carbon::setTestNow(Carbon::now('UTC')->addHour());
|
||||
|
||||
$pirep->notes = 'reviewed by an administrator';
|
||||
$pirep->save();
|
||||
|
||||
expect(positionRow($pirep)->updated_at->timestamp)->toBe($afterBatch->timestamp);
|
||||
|
||||
postPositions($pirep, [point(Carbon::now('UTC'), 6.0, 6.0)]);
|
||||
|
||||
expect(positionRow($pirep)->updated_at->timestamp)->toBeGreaterThan($afterBatch->timestamp);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('the update endpoint writes no position row and emits what it always did', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
positionRow($pirep)->delete();
|
||||
|
||||
Event::fake([PirepUpdated::class]);
|
||||
|
||||
test()->post('/api/pireps/'.$pirep->id.'/update', [
|
||||
'flight_time' => 90,
|
||||
'distance' => 20,
|
||||
'status' => PirepPhase::AIRBORNE->value,
|
||||
])->assertStatus(200);
|
||||
|
||||
Event::assertDispatched(PirepUpdated::class);
|
||||
|
||||
expect(positionRow($pirep))->toBeNull()
|
||||
->and(Pirep::find($pirep->id)->flight_time)->toBe(90);
|
||||
});
|
||||
|
||||
test('the file endpoint writes no position row', function (): void {
|
||||
[$pirep] = prefileFlight();
|
||||
positionRow($pirep)->delete();
|
||||
|
||||
test()->post('/api/pireps/'.$pirep->id.'/file', [
|
||||
'flight_time' => 60,
|
||||
'fuel_used' => 100,
|
||||
'distance' => 100,
|
||||
])->assertStatus(200);
|
||||
|
||||
expect(positionRow($pirep))->toBeNull();
|
||||
});
|
||||
170
tests/Feature/PirepTelemetryCleanupTest.php
Normal file
170
tests/Feature/PirepTelemetryCleanupTest.php
Normal file
@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Cron\Hourly\DeletePireps;
|
||||
use App\Enums\AcarsType;
|
||||
use App\Enums\PirepState;
|
||||
use App\Events\CronHourly;
|
||||
use App\Models\Acars;
|
||||
use App\Models\Pirep;
|
||||
use App\Models\PirepPosition;
|
||||
use App\Services\PirepService;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* SQLite has no ALTER TABLE ADD CONSTRAINT, so the migration skips it there. A test
|
||||
* asserting an FK works, on a connection with no FK, is worse than no test.
|
||||
*/
|
||||
function acarsForeignKeyExists(): bool
|
||||
{
|
||||
return collect(Schema::getForeignKeys('acars'))
|
||||
->flatMap(fn (array $key): array => $key['columns'])
|
||||
->contains('pirep_id');
|
||||
}
|
||||
|
||||
function flightWithTelemetry(array $attrs = []): Pirep
|
||||
{
|
||||
$pirep = Pirep::factory()->create($attrs);
|
||||
|
||||
Acars::factory()->count(3)->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'type' => AcarsType::FLIGHT_PATH,
|
||||
]);
|
||||
|
||||
PirepPosition::factory()->create([
|
||||
'pirep_id' => $pirep->id,
|
||||
'user_id' => $pirep->user_id,
|
||||
]);
|
||||
|
||||
return $pirep;
|
||||
}
|
||||
|
||||
function telemetryCount(Pirep $pirep): array
|
||||
{
|
||||
return [
|
||||
'acars' => DB::table('acars')->where('pirep_id', $pirep->id)->count(),
|
||||
'position' => DB::table('pirep_positions')->where('pirep_id', $pirep->id)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
test('deleting a PIREP through the service removes its telemetry', function (): void {
|
||||
$pirep = flightWithTelemetry();
|
||||
|
||||
expect(telemetryCount($pirep))->toBe(['acars' => 3, 'position' => 1]);
|
||||
|
||||
app(PirepService::class)->delete($pirep);
|
||||
|
||||
// The docblock has claimed `acars` since this method was written.
|
||||
expect(telemetryCount($pirep))->toBe(['acars' => 0, 'position' => 0])
|
||||
->and(Pirep::withTrashed()->find($pirep->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('a soft-deleted PIREP keeps its telemetry', function (): void {
|
||||
$pirep = flightWithTelemetry();
|
||||
|
||||
$pirep->delete();
|
||||
|
||||
// The PIREP still exists and may be restored.
|
||||
expect(telemetryCount($pirep))->toBe(['acars' => 3, 'position' => 1])
|
||||
->and(Pirep::withTrashed()->find($pirep->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a soft-deleted PIREP is not counted as an orphan parent', function (): void {
|
||||
$pirep = flightWithTelemetry();
|
||||
$pirep->delete();
|
||||
|
||||
// The migration's anti-join. Through Eloquent the SoftDeletes scope would hide
|
||||
// the parent and this would destroy live telemetry.
|
||||
$orphans = DB::table('acars')
|
||||
->whereNotNull('acars.pirep_id')
|
||||
->whereNotExists(fn ($query) => $query->select(DB::raw(1))
|
||||
->from('pireps')
|
||||
->whereColumn('pireps.id', 'acars.pirep_id'))
|
||||
->count();
|
||||
|
||||
expect($orphans)->toBe(0);
|
||||
});
|
||||
|
||||
test('deleting a PIREP outside the service still removes its telemetry', function (): void {
|
||||
if (!acarsForeignKeyExists()) {
|
||||
test()->markTestSkipped('The acars foreign key was skipped on this platform, so there is nothing to assert.');
|
||||
}
|
||||
|
||||
$pirep = flightWithTelemetry();
|
||||
|
||||
// Bypassing the service, the model and the observers.
|
||||
DB::table('pireps')->where('id', $pirep->id)->delete();
|
||||
|
||||
expect(telemetryCount($pirep))->toBe(['acars' => 0, 'position' => 0]);
|
||||
});
|
||||
|
||||
test('telemetry for a PIREP that does not exist is rejected', function (): void {
|
||||
if (!acarsForeignKeyExists()) {
|
||||
test()->markTestSkipped('The acars foreign key was skipped on this platform, so there is nothing to assert.');
|
||||
}
|
||||
|
||||
expect(fn () => Acars::factory()->create([
|
||||
'pirep_id' => 'no-such-pirep-id',
|
||||
'type' => AcarsType::FLIGHT_PATH,
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('the position row cascades on every platform', function (): void {
|
||||
// Declared at create time, so it exists everywhere including SQLite.
|
||||
$pirep = flightWithTelemetry();
|
||||
|
||||
DB::table('pireps')->where('id', $pirep->id)->delete();
|
||||
|
||||
expect(DB::table('pirep_positions')->where('pirep_id', $pirep->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('the scheduled cancelled and rejected cleanup leaves no telemetry behind', function (): void {
|
||||
updateSetting('pireps.delete_cancelled_hours', 1);
|
||||
updateSetting('pireps.delete_rejected_hours', 1);
|
||||
|
||||
$cancelled = flightWithTelemetry(['state' => PirepState::CANCELLED]);
|
||||
$rejected = flightWithTelemetry(['state' => PirepState::REJECTED]);
|
||||
|
||||
foreach ([$cancelled, $rejected] as $pirep) {
|
||||
DB::table('pireps')->where('id', $pirep->id)
|
||||
->update(['created_at' => Carbon::now('UTC')->subHours(5)]);
|
||||
}
|
||||
|
||||
app(DeletePireps::class)->handle(new CronHourly());
|
||||
|
||||
expect(telemetryCount($cancelled))->toBe(['acars' => 0, 'position' => 0])
|
||||
->and(telemetryCount($rejected))->toBe(['acars' => 0, 'position' => 0]);
|
||||
});
|
||||
|
||||
test('replacing a stored route still works under the constraint', function (): void {
|
||||
$pirep = Pirep::factory()->create();
|
||||
|
||||
Acars::factory()->count(2)->create(['pirep_id' => $pirep->id, 'type' => AcarsType::ROUTE]);
|
||||
Acars::factory()->count(2)->create(['pirep_id' => $pirep->id, 'type' => AcarsType::FLIGHT_PATH]);
|
||||
|
||||
// What route_post and saveRoute do. Unrelated to PIREP deletion.
|
||||
Acars::where('pirep_id', $pirep->id)->where('type', AcarsType::ROUTE)->delete();
|
||||
|
||||
expect(Acars::where('pirep_id', $pirep->id)->where('type', AcarsType::ROUTE)->count())->toBe(0)
|
||||
->and(Acars::where('pirep_id', $pirep->id)->flightPath()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('clearing logs and flight path on a reused leg still works', function (): void {
|
||||
$pirep = Pirep::factory()->create();
|
||||
|
||||
Acars::factory()->count(2)->create(['pirep_id' => $pirep->id, 'type' => AcarsType::LOG]);
|
||||
Acars::factory()->count(2)->create(['pirep_id' => $pirep->id, 'type' => AcarsType::FLIGHT_PATH]);
|
||||
Acars::factory()->count(1)->create(['pirep_id' => $pirep->id, 'type' => AcarsType::ROUTE]);
|
||||
|
||||
// What prefile does to a reused duplicate leg.
|
||||
Acars::where('pirep_id', $pirep->id)
|
||||
->whereIn('type', [AcarsType::FLIGHT_PATH, AcarsType::LOG])
|
||||
->delete();
|
||||
|
||||
expect(Acars::where('pirep_id', $pirep->id)->count())->toBe(1)
|
||||
->and(Pirep::find($pirep->id))->not->toBeNull();
|
||||
});
|
||||
@ -4,6 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
use App\Enums\AcarsType;
|
||||
use App\Models\Acars;
|
||||
use App\Models\Pirep;
|
||||
|
||||
/**
|
||||
* `acars`.`pirep_id` carries a foreign key on every platform that can express
|
||||
* one, so these rows need real parents rather than invented ids.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
Pirep::factory()->create(['id' => 'PIREP-A']);
|
||||
Pirep::factory()->create(['id' => 'PIREP-B']);
|
||||
});
|
||||
|
||||
test('Acars::forPirep returns only matching pirep rows', function (): void {
|
||||
Acars::factory()->create([
|
||||
|
||||
Loading…
Reference in New Issue
Block a user