Files
autocrm/app/Console/Commands/SendTireSeasonalRemindersCommand.php
Vasyka 8fdfc9ef85 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>
2026-06-02 19:31:24 +00:00

115 lines
4.6 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}