diff --git a/app/Console/Commands/ArchiveOldPricesCommand.php b/app/Console/Commands/ArchiveOldPricesCommand.php new file mode 100644 index 0000000..b9c402f --- /dev/null +++ b/app/Console/Commands/ArchiveOldPricesCommand.php @@ -0,0 +1,52 @@ +subMonths(12); + + $count = StationPrice::where('price_effective_at', '<', $cutoff)->count(); + + if ($count === 0) { + $this->info('No prices to archive.'); + + return self::SUCCESS; + } + + $this->info("Archiving {$count} price record(s) older than {$cutoff->toDateString()}..."); + + StationPrice::where('price_effective_at', '<', $cutoff) + ->chunkById(1000, function ($prices): void { + $rows = $prices->map(fn (StationPrice $price): array => [ + 'station_id' => $price->station_id, + 'fuel_type' => $price->fuel_type->value, + 'price_pence' => $price->price_pence, + 'price_effective_at' => $price->price_effective_at, + 'price_reported_at' => $price->price_reported_at, + 'recorded_at' => $price->recorded_at, + ])->all(); + + DB::transaction(function () use ($rows, $prices): void { + StationPriceArchive::insert($rows); + StationPrice::whereIn('id', $prices->pluck('id'))->delete(); + }); + }); + + $this->info('Archive complete.'); + + return self::SUCCESS; + } +} diff --git a/app/Models/StationPriceArchive.php b/app/Models/StationPriceArchive.php index 7569d0f..391b5bf 100644 --- a/app/Models/StationPriceArchive.php +++ b/app/Models/StationPriceArchive.php @@ -10,15 +10,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; #[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])] class StationPriceArchive extends Model { + protected $table = 'station_prices_archive'; + public $timestamps = false; protected function casts(): array { return [ - 'fuel_type' => FuelType::class, + 'fuel_type' => FuelType::class, 'price_effective_at' => 'datetime', - 'price_reported_at' => 'datetime', - 'recorded_at' => 'datetime', + 'price_reported_at' => 'datetime', + 'recorded_at' => 'datetime', ]; } diff --git a/routes/console.php b/routes/console.php index 0f6ce57..b637299 100644 --- a/routes/console.php +++ b/routes/console.php @@ -33,6 +33,14 @@ Schedule::command('oil:predict --fetch') ->onOneServer() ->runInBackground(); +// Move station_prices rows older than 12 months into station_prices_archive +// once a month. Keeps the partitioned hot table bounded. +Schedule::command('fuel:archive') + ->monthlyOn(1, '04:00') + ->withoutOverlapping() + ->onOneServer() + ->runInBackground(); + // Scheduled WhatsApp updates — morning and evening Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer(); Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer(); diff --git a/tests/Feature/Commands/ArchiveOldPricesCommandTest.php b/tests/Feature/Commands/ArchiveOldPricesCommandTest.php new file mode 100644 index 0000000..e5abd27 --- /dev/null +++ b/tests/Feature/Commands/ArchiveOldPricesCommandTest.php @@ -0,0 +1,69 @@ +create(); + + StationPrice::factory()->create([ + 'station_id' => $station->node_id, + 'price_effective_at' => now()->subMonths(13), + 'price_reported_at' => now()->subMonths(13), + 'recorded_at' => now()->subMonths(13), + ]); + + StationPrice::factory()->create([ + 'station_id' => $station->node_id, + 'price_effective_at' => now()->subMonths(6), + 'price_reported_at' => now()->subMonths(6), + 'recorded_at' => now()->subMonths(6), + ]); + + $this->artisan('fuel:archive')->assertSuccessful(); + + expect(StationPrice::count())->toBe(1) + ->and(StationPriceArchive::count())->toBe(1); +}); + +it('outputs no-op message when nothing qualifies', function (): void { + $station = Station::factory()->create(); + + StationPrice::factory()->create([ + 'station_id' => $station->node_id, + 'price_effective_at' => now()->subMonths(3), + 'price_reported_at' => now()->subMonths(3), + 'recorded_at' => now()->subMonths(3), + ]); + + $this->artisan('fuel:archive') + ->expectsOutputToContain('No prices to archive.') + ->assertSuccessful(); + + expect(StationPrice::count())->toBe(1) + ->and(StationPriceArchive::count())->toBe(0); +}); + +it('preserves the row data when archiving', function (): void { + $station = Station::factory()->create(); + + $original = StationPrice::factory()->create([ + 'station_id' => $station->node_id, + 'price_pence' => 14523, + 'price_effective_at' => now()->subMonths(13), + 'price_reported_at' => now()->subMonths(13), + 'recorded_at' => now()->subMonths(13), + ]); + + $this->artisan('fuel:archive')->assertSuccessful(); + + $archived = StationPriceArchive::first(); + + expect($archived)->not->toBeNull() + ->and($archived->station_id)->toBe($original->station_id) + ->and($archived->price_pence)->toBe(14523); +});