feat: Part product images + seasonal tire-swap reminders

Part (HasMedia):
- Spatie media `image` single-file collection + imageUrl() helper
- PartResource form: image upload section (image editor, 2 MB max)
- Parts list: circular thumbnail column
- Shop catalog cards: square thumbnail + 📦 placeholder
- Shop part detail: 260px image alongside info, single column when no image

Seasonal tire-swap reminders:
- NotificationDispatcher::tireSeasonalSwap(TireSet) — Telegram first, email
  fallback (when set has a vehicle, via ServiceReminderMail with 'tire_swap'
  type and a size-aware note)
- tires:remind-seasonal artisan command, self-gating to Feb 15-Mar 15
  (notify winter sets stored) and Sep 15-Oct 15 (notify summer sets stored).
  60-day cooldown per client via service_reminders_sent. --force / --dry-run.
- Schedule: weekly Mon 09:30

Tests (6 new):
- outside window no-ops; spring window notifies winter; spring ignores summer;
  autumn notifies summer; cooldown blocks doubles; --force overrides window

Full suite: 106 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 19:31:24 +00:00
parent b9ff9c6583
commit 8fdfc9ef85
9 changed files with 357 additions and 5 deletions
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\ServiceReminderSent;
use App\Models\Tenant\TireSet;
use App\Models\Tenant\TireStorage;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class TireSeasonalReminderTest extends TestCase
{
use RefreshDatabase;
public function test_outside_window_does_nothing(): void
{
Carbon::setTestNow(Carbon::create(2026, 6, 2));
$this->makeStoredSet('off', season: 'winter');
\Illuminate\Support\Facades\Mail::fake();
$this->artisan('tires:remind-seasonal')->assertSuccessful();
$this->assertEquals(0, ServiceReminderSent::where('type', 'tire_swap')->count());
Carbon::setTestNow();
}
public function test_spring_window_notifies_winter_stored_sets(): void
{
Carbon::setTestNow(Carbon::create(2026, 3, 1));
$ctx = $this->makeStoredSet('spr', season: 'winter');
\Illuminate\Support\Facades\Mail::fake();
$this->artisan('tires:remind-seasonal')->assertSuccessful();
$sent = ServiceReminderSent::where('type', 'tire_swap')
->where('client_id', $ctx['client']->id)->first();
$this->assertNotNull($sent);
Carbon::setTestNow();
}
public function test_spring_window_ignores_summer_sets(): void
{
Carbon::setTestNow(Carbon::create(2026, 3, 1));
$this->makeStoredSet('sum', season: 'summer');
\Illuminate\Support\Facades\Mail::fake();
$this->artisan('tires:remind-seasonal')->assertSuccessful();
$this->assertEquals(0, ServiceReminderSent::where('type', 'tire_swap')->count());
Carbon::setTestNow();
}
public function test_autumn_window_notifies_summer_stored_sets(): void
{
Carbon::setTestNow(Carbon::create(2026, 10, 1));
$ctx = $this->makeStoredSet('aut', season: 'summer');
\Illuminate\Support\Facades\Mail::fake();
$this->artisan('tires:remind-seasonal')->assertSuccessful();
$sent = ServiceReminderSent::where('type', 'tire_swap')->first();
$this->assertNotNull($sent);
$this->assertEquals($ctx['client']->id, $sent->client_id);
Carbon::setTestNow();
}
public function test_cooldown_prevents_double_send(): void
{
Carbon::setTestNow(Carbon::create(2026, 3, 1));
$ctx = $this->makeStoredSet('cd', season: 'winter');
\Illuminate\Support\Facades\Mail::fake();
$this->artisan('tires:remind-seasonal')->assertSuccessful();
$this->assertEquals(1, ServiceReminderSent::where('type', 'tire_swap')->count());
// Second run same day → cooldown blocks it.
$this->artisan('tires:remind-seasonal')->assertSuccessful();
$this->assertEquals(1, ServiceReminderSent::where('type', 'tire_swap')->count());
Carbon::setTestNow();
}
public function test_force_runs_outside_window(): void
{
Carbon::setTestNow(Carbon::create(2026, 6, 2));
$ctx = $this->makeStoredSet('force', season: 'winter');
\Illuminate\Support\Facades\Mail::fake();
$this->artisan('tires:remind-seasonal', ['--force' => true])->assertSuccessful();
$this->assertEquals(1, ServiceReminderSent::where('type', 'tire_swap')->count());
Carbon::setTestNow();
}
private function makeStoredSet(string $slug, string $season): array
{
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
$company = Company::create([
'plan_id' => $plan->id, 'slug' => $slug,
'name' => ucfirst($slug), 'status' => 'active',
'settings' => ['telegram' => ['bot_token' => 'FAKE:TOKEN']],
]);
app(TenantManager::class)->setCurrent($company);
$client = Client::create([
'name' => 'TireClient', 'phone' => '+3736' . random_int(1000000, 9999999),
'email' => $slug . '@example.com',
'telegram_chat_id' => '9999' . random_int(1000, 9999),
'type' => 'individual', 'status' => 'active',
]);
$vehicle = \App\Models\Tenant\Vehicle::create([
'client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5',
'plate' => 'TS-' . random_int(100, 999),
]);
$set = TireSet::create([
'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
'season' => $season, 'brand' => 'Michelin',
'width' => 205, 'profile' => 55, 'diameter' => 16,
]);
TireStorage::create([
'tire_set_id' => $set->id, 'location' => 'A1-' . $slug,
'status' => 'stored', 'checked_in_at' => now()->subMonths(2),
]);
// Telegram dispatch goes through HTTP; fake it so the test doesn't
// hit the real Bot API.
\Illuminate\Support\Facades\Http::fake([
'api.telegram.org/*' => \Illuminate\Support\Facades\Http::response(['ok' => true]),
]);
return compact('company', 'client', 'vehicle', 'set');
}
}