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:
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Tenant\ServiceReminderSent;
|
||||
use App\Models\Tenant\TireSet;
|
||||
use App\Services\NotificationDispatcher;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Twice-a-year window reminder for clients to swap seasonal tires.
|
||||
* - Around March 1 (Feb 15 – Mar 15): notify clients with WINTER sets still
|
||||
* in storage (time to swap to summer).
|
||||
* - Around October 1 (Sep 15 – Oct 15): notify clients with SUMMER sets still
|
||||
* in storage (time to swap to winter).
|
||||
*
|
||||
* Dedup via service_reminders_sent (type='tire_swap', per client+set, 60-day
|
||||
* cooldown — effectively once per window).
|
||||
*/
|
||||
class SendTireSeasonalRemindersCommand extends Command
|
||||
{
|
||||
protected $signature = 'tires:remind-seasonal
|
||||
{--slug= : Only one tenant by slug}
|
||||
{--force : Send even outside the swap window}
|
||||
{--dry-run : Show candidates without sending}';
|
||||
|
||||
protected $description = 'Send seasonal tire-swap reminders during Feb-Mar / Sep-Oct windows.';
|
||||
|
||||
public function handle(NotificationDispatcher $dispatcher): int
|
||||
{
|
||||
$window = $this->windowFor(today());
|
||||
$force = (bool) $this->option('force');
|
||||
$dry = (bool) $this->option('dry-run');
|
||||
|
||||
if (! $window && ! $force) {
|
||||
$this->info('Outside swap window. Use --force to run anyway. Today: ' . today()->toDateString());
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$targetSeason = $window['season'] ?? 'winter'; // season of stored sets we want to notify
|
||||
|
||||
$query = Company::query()->where('status', '!=', 'archived');
|
||||
if ($slug = $this->option('slug')) $query->where('slug', $slug);
|
||||
$companies = $query->get();
|
||||
|
||||
$totalSent = 0;
|
||||
$cooldown = today()->subDays(60);
|
||||
|
||||
foreach ($companies as $company) {
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
// Sets currently in storage whose season matches the window target.
|
||||
$sets = TireSet::with(['client', 'vehicle', 'storage'])
|
||||
->where('season', $targetSeason)
|
||||
->whereHas('storage', fn ($s) => $s->where('status', 'stored'))
|
||||
->get()
|
||||
->filter(fn (TireSet $s) => $s->client && $s->client->status === 'active');
|
||||
|
||||
$sentThisTenant = 0;
|
||||
foreach ($sets as $set) {
|
||||
$recent = ServiceReminderSent::where('type', 'tire_swap')
|
||||
->where('client_id', $set->client_id)
|
||||
->where('sent_at', '>=', $cooldown)
|
||||
->exists();
|
||||
if ($recent) continue;
|
||||
|
||||
if ($dry) {
|
||||
$this->line(sprintf(' - [%s] set #%d %s · client %s · loc %s',
|
||||
$company->slug, $set->id, $set->sizeLabel(),
|
||||
$set->client?->name ?? '—',
|
||||
$set->currentStorage()?->location ?? '—'));
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = $dispatcher->tireSeasonalSwap($set);
|
||||
if ($ok) {
|
||||
ServiceReminderSent::create([
|
||||
'company_id' => $company->id,
|
||||
'vehicle_id' => $set->vehicle_id,
|
||||
'client_id' => $set->client_id,
|
||||
'channel' => $set->client?->telegram_chat_id ? 'telegram' : 'email',
|
||||
'type' => 'tire_swap',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
$sentThisTenant++;
|
||||
}
|
||||
}
|
||||
$this->info(sprintf('[%s] tire-swap reminders sent: %d', $company->slug, $sentThisTenant));
|
||||
$totalSent += $sentThisTenant;
|
||||
}
|
||||
|
||||
$this->info("Total tire-swap reminders sent: {$totalSent}" . ($dry ? ' (dry run)' : ''));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** Returns ['season' => 'winter'|'summer'] if today is in a swap window, else null. */
|
||||
private function windowFor(Carbon $today): ?array
|
||||
{
|
||||
// Feb 15 – Mar 15 → notify WINTER sets (swap to summer).
|
||||
$springStart = Carbon::create($today->year, 2, 15);
|
||||
$springEnd = Carbon::create($today->year, 3, 15);
|
||||
if ($today->between($springStart, $springEnd)) return ['season' => 'winter'];
|
||||
|
||||
// Sep 15 – Oct 15 → notify SUMMER sets (swap to winter).
|
||||
$autumnStart = Carbon::create($today->year, 9, 15);
|
||||
$autumnEnd = Carbon::create($today->year, 10, 15);
|
||||
if ($today->between($autumnStart, $autumnEnd)) return ['season' => 'summer'];
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user