85ef2f6e00
Schema:
- clients.telegram_chat_id (linked via /start contact-share)
- clients.notify_prefs (per-client channel order override)
- service_reminders_sent (dedup ledger for the daily cron)
Telegram (per tenant):
- TelegramService (sendMessage, getMe, setWebhook with auto-generated secret)
- Bot token stored in companies.settings.telegram.bot_token
- Webhook /telegram/webhook/{slug} validates X-Telegram-Bot-Api-Secret-Token,
matches client by last 9 digits of phone, persists chat_id, replies confirm
- /start prompts share-contact; /stop unlinks chat_id
NotificationDispatcher refactor:
- Multi-channel: telegram first if chat_id + bot configured, then email
- Backwards-compat with legacy boolean notify.{type} flags
- 4 HTML-formatted Telegram messages (wo_ready with tracking link, payment,
appointment, reminder)
Service reminders:
- `reminders:send` artisan command with --slug / --dry-run
- Policy: vehicles whose last closed WO is older than reminder.after_days
(default 365). Skips if sent within reminder.cooldown_days (default 30).
- Schedule daily 09:00
Filament UI:
- Settings page: Telegram bot token field + "Test bot" + "Set webhook" actions
- Settings page: reminder_after_days + reminder_cooldown_days inputs
- ClientResource: telegram_chat_id readonly badge
Tests (6 new, all pass):
- webhook links client via shared contact
- webhook rejects wrong secret → 401
- dispatcher uses telegram when chat_id present (Http::fake)
- dispatcher falls back to email otherwise
- dispatcher returns false when no channel available
- reminder cron respects 30-day cooldown
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
89 lines
3.3 KiB
PHP
89 lines
3.3 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Central\Company;
|
|
use App\Models\Tenant\ServiceReminderSent;
|
|
use App\Models\Tenant\Vehicle;
|
|
use App\Models\Tenant\WorkOrder;
|
|
use App\Services\NotificationDispatcher;
|
|
use App\Tenancy\TenantManager;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
class SendServiceRemindersCommand extends Command
|
|
{
|
|
protected $signature = 'reminders:send
|
|
{--slug= : Only one tenant by slug}
|
|
{--dry-run : Show candidates without sending}';
|
|
|
|
protected $description = 'Scan vehicles for due service reminders and send via configured channels.';
|
|
|
|
public function handle(NotificationDispatcher $dispatcher): int
|
|
{
|
|
$query = Company::query()->where('status', '!=', 'archived');
|
|
if ($slug = $this->option('slug')) {
|
|
$query->where('slug', $slug);
|
|
}
|
|
$companies = $query->get();
|
|
$dry = (bool) $this->option('dry-run');
|
|
|
|
$totalSent = 0;
|
|
|
|
foreach ($companies as $company) {
|
|
app(TenantManager::class)->setCurrent($company);
|
|
|
|
$settings = (array) ($company->settings ?? []);
|
|
$reminderDays = (int) data_get($settings, 'reminder.after_days', 365);
|
|
$cooldownDays = (int) data_get($settings, 'reminder.cooldown_days', 30);
|
|
|
|
$cutoff = Carbon::now()->subDays($reminderDays);
|
|
$cooldown = Carbon::now()->subDays($cooldownDays);
|
|
|
|
// Pick vehicles whose last *closed* WO was before $cutoff (or never).
|
|
$vehicles = Vehicle::with('client')
|
|
->whereHas('client', fn ($q) => $q->where('status', 'active'))
|
|
->get();
|
|
|
|
$sentThisTenant = 0;
|
|
foreach ($vehicles as $v) {
|
|
$lastClosedAt = WorkOrder::where('vehicle_id', $v->id)
|
|
->whereNotNull('closed_at')
|
|
->max('closed_at');
|
|
|
|
if (! $lastClosedAt) continue; // never serviced — skip (handled by other logic)
|
|
if (Carbon::parse($lastClosedAt)->gt($cutoff)) continue;
|
|
|
|
$recent = ServiceReminderSent::where('vehicle_id', $v->id)
|
|
->where('sent_at', '>=', $cooldown)
|
|
->exists();
|
|
if ($recent) continue;
|
|
|
|
if ($dry) {
|
|
$this->line(" - [{$company->slug}] Vehicle #{$v->id} {$v->plate} last serviced {$lastClosedAt}");
|
|
continue;
|
|
}
|
|
|
|
$ok = $dispatcher->serviceReminder($v, 'general');
|
|
if ($ok) {
|
|
ServiceReminderSent::create([
|
|
'company_id' => $company->id,
|
|
'vehicle_id' => $v->id,
|
|
'client_id' => $v->client_id,
|
|
'channel' => $v->client?->telegram_chat_id ? 'telegram' : 'email',
|
|
'type' => 'general',
|
|
'sent_at' => now(),
|
|
]);
|
|
$sentThisTenant++;
|
|
}
|
|
}
|
|
|
|
$this->info(sprintf('[%s] reminders sent: %d', $company->slug, $sentThisTenant));
|
|
$totalSent += $sentThisTenant;
|
|
}
|
|
|
|
$this->info("Total reminders sent: {$totalSent}" . ($dry ? ' (dry run)' : ''));
|
|
return self::SUCCESS;
|
|
}
|
|
}
|