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
@@ -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;
}
}
@@ -99,6 +99,18 @@ class PartResource extends Resource
->options(fn () => Supplier::pluck('name', 'id'))
->searchable(),
]),
Schemas\Components\Section::make('Imagine')
->collapsible()
->schema([
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('image')
->label('Foto piesă')
->collection('image')
->image()
->imageEditor()
->maxSize(2048)
->columnSpanFull()
->helperText('Apare în magazinul online (catalog + pagina piesei). Max 2 MB.'),
]),
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
]);
}
@@ -107,6 +119,11 @@ class PartResource extends Resource
{
return $table
->columns([
\Filament\Tables\Columns\SpatieMediaLibraryImageColumn::make('image')
->label('')
->collection('image')
->circular()
->size(32),
Tables\Columns\TextColumn::make('name')->searchable()->sortable()->wrap(),
Tables\Columns\TextColumn::make('article')->label('Cod')->searchable()->copyable()->placeholder('—'),
Tables\Columns\TextColumn::make('brand')->placeholder('—'),
+17 -2
View File
@@ -7,10 +7,25 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Part extends Model
class Part extends Model implements HasMedia
{
use BelongsToTenant, SoftDeletes;
use BelongsToTenant, InteractsWithMedia, SoftDeletes;
public function registerMediaCollections(): void
{
$this->addMediaCollection('image')->singleFile();
}
public function imageUrl(): ?string
{
$m = $this->getFirstMedia('image');
if (! $m) return null;
if (! @file_exists($m->getPath())) return null;
return $m->getUrl();
}
public const CATEGORIES = [
'Ulei', 'Filtre', 'Frâne', 'Suspensie', 'Lichide',
+37
View File
@@ -95,6 +95,43 @@ class NotificationDispatcher
]);
}
public function tireSeasonalSwap(\App\Models\Tenant\TireSet $set): bool
{
$company = $this->companyFor($set);
$client = $set->client;
if (! $client) return false;
return $this->dispatch($company, $client, 'reminder', [
'telegram' => fn () => $this->tgTireSeasonalSwap($set, $company, $client),
'email' => fn () => $set->vehicle ? $this->emailSafe(
fn () => Mail::to($client->email)->send(new ServiceReminderMail(
$set->vehicle,
'tire_swap',
'E timpul să schimbi anvelopele ' . ($set->season === 'winter' ? 'de iarnă' : 'de vară') .
' (' . $set->sizeLabel() . ').',
$company
)),
'tireSeasonalSwap', ['set' => $set->id]
) : false,
]);
}
protected function tgTireSeasonalSwap(\App\Models\Tenant\TireSet $set, Company $company, Client $client): bool
{
$brand = htmlspecialchars($company->display_name ?? $company->name);
$size = htmlspecialchars($set->sizeLabel());
$seasonRo = $set->season === 'winter' ? 'de iarnă' : 'de vară';
$loc = $set->currentStorage()?->location;
$plate = $set->vehicle?->plate ? ' · ' . htmlspecialchars($set->vehicle->plate) : '';
$text = "🔧 <b>Schimb sezonier anvelope</b>\n"
. "Setul tău {$seasonRo} ({$size}){$plate}"
. ($loc ? " e în depozit la <b>{$loc}</b>." : '.')
. "\n\nProgramează-te la <b>{$brand}</b>.";
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
}
// ─── Channel dispatch ─────────────────────────────────────────
/**