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;
|
||||
}
|
||||
}
|
||||
@@ -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('—'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,8 +25,15 @@
|
||||
@else
|
||||
<div class="grid">
|
||||
@foreach ($parts as $p)
|
||||
@php $stock = (float) $p->qty; @endphp
|
||||
@php $stock = (float) $p->qty; $img = $p->imageUrl(); @endphp
|
||||
<div class="product">
|
||||
<a href="/shop/part/{{ $p->id }}" class="product-thumb">
|
||||
@if ($img)
|
||||
<img src="{{ $img }}" alt="{{ $p->name }}" loading="lazy">
|
||||
@else
|
||||
<div class="product-thumb-empty">📦</div>
|
||||
@endif
|
||||
</a>
|
||||
<a href="/shop/part/{{ $p->id }}">
|
||||
<h3>{{ $p->name }}</h3>
|
||||
</a>
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||||
.product { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 14px; display: flex; flex-direction: column; }
|
||||
.product-thumb { display: block; aspect-ratio: 1; margin-bottom: 10px; border-radius: 8px; overflow: hidden; background: #f9fafb; border: 1px solid #f3f4f6; }
|
||||
.product-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.product-thumb-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 36px; color: #cbd5e1; }
|
||||
.product h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; min-height: 38px; }
|
||||
.product .meta { font-size: 12px; color: #6b7280; margin-bottom: 8px; }
|
||||
.product .price { font-size: 18px; font-weight: 700; color: {{ $themeColor }}; margin-top: auto; }
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
@extends('shop.layout')
|
||||
@section('title', $part->name)
|
||||
@section('content')
|
||||
@php $currency = $tenant->settings['currency'] ?? 'MDL'; $stock = (float) $part->qty; @endphp
|
||||
@php $currency = $tenant->settings['currency'] ?? 'MDL'; $stock = (float) $part->qty; $img = $part->imageUrl(); @endphp
|
||||
|
||||
<a href="/shop" class="muted">← Înapoi la catalog</a>
|
||||
|
||||
@if ($img)
|
||||
<div class="card" style="margin-top:12px;display:grid;grid-template-columns:260px 1fr;gap:20px;align-items:start;">
|
||||
<div style="border-radius:10px;overflow:hidden;aspect-ratio:1;background:#f9fafb;border:1px solid #e5e7eb;">
|
||||
<img src="{{ $img }}" alt="{{ $part->name }}" style="width:100%;height:100%;object-fit:cover;display:block;">
|
||||
</div>
|
||||
<div>
|
||||
@else
|
||||
<div class="card" style="margin-top:12px;">
|
||||
@endif
|
||||
<h1 style="font-size:22px;margin-bottom:8px;">{{ $part->name }}</h1>
|
||||
<div class="muted" style="margin-bottom:14px;">
|
||||
{{ $part->brand ? 'Brand: ' . $part->brand : '' }}
|
||||
@@ -45,5 +53,10 @@
|
||||
<p class="muted" style="white-space:pre-wrap;">{{ $part->notes }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@if ($img)
|
||||
</div>{{-- /right column --}}
|
||||
</div>{{-- /card grid --}}
|
||||
@else
|
||||
</div>{{-- /card --}}
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@@ -30,3 +30,10 @@ ScheduleFacade::command('reminders:send')
|
||||
->dailyAt('09:00')
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
|
||||
// Weekly seasonal tire-swap reminders — Monday 09:30. Self-gates to the
|
||||
// Feb 15-Mar 15 / Sep 15-Oct 15 windows; outside them it no-ops.
|
||||
ScheduleFacade::command('tires:remind-seasonal')
|
||||
->weeklyOn(1, '09:30')
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user