8fdfc9ef85
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>
271 lines
11 KiB
PHP
271 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Mail\AppointmentConfirmedMail;
|
|
use App\Mail\PaymentReceivedMail;
|
|
use App\Mail\ServiceReminderMail;
|
|
use App\Mail\WorkOrderReadyMail;
|
|
use App\Models\Central\Company;
|
|
use App\Models\Tenant\Appointment;
|
|
use App\Models\Tenant\Client;
|
|
use App\Models\Tenant\Payment;
|
|
use App\Models\Tenant\Vehicle;
|
|
use App\Models\Tenant\WorkOrder;
|
|
use App\Services\Notifications\TelegramService;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
/**
|
|
* Multi-channel outbound notifications with per-tenant + per-client opt-in/out.
|
|
*
|
|
* Channels tried in order:
|
|
* 1. Telegram — if client has telegram_chat_id AND tenant bot token AND
|
|
* tenant has notify.{type}.telegram = true (default true when bot configured)
|
|
* 2. Email — if client has email AND tenant has notify.{type}.email = true
|
|
* (default true — preserves legacy behaviour)
|
|
*
|
|
* Per-client overrides via clients.notify_prefs JSON (array of channel keys).
|
|
*
|
|
* Public methods return TRUE when at least one channel succeeded.
|
|
*/
|
|
class NotificationDispatcher
|
|
{
|
|
public function __construct(private TelegramService $telegram)
|
|
{
|
|
}
|
|
|
|
public function workOrderReady(WorkOrder $wo): bool
|
|
{
|
|
$company = $this->companyFor($wo);
|
|
$client = $wo->client;
|
|
if (! $client) return false;
|
|
|
|
return $this->dispatch($company, $client, 'wo_ready', [
|
|
'telegram' => fn () => $this->tgWorkOrderReady($wo, $company, $client),
|
|
'email' => fn () => $this->emailSafe(
|
|
fn () => Mail::to($client->email)->send(new WorkOrderReadyMail($wo, $company)),
|
|
'workOrderReady', ['wo' => $wo->id]
|
|
),
|
|
]);
|
|
}
|
|
|
|
public function paymentReceived(Payment $payment): bool
|
|
{
|
|
$company = $this->companyFor($payment);
|
|
$client = $payment->client;
|
|
if (! $client) return false;
|
|
|
|
return $this->dispatch($company, $client, 'payment', [
|
|
'telegram' => fn () => $this->tgPaymentReceived($payment, $company, $client),
|
|
'email' => fn () => $this->emailSafe(
|
|
fn () => Mail::to($client->email)->send(new PaymentReceivedMail($payment, $company)),
|
|
'paymentReceived', ['payment' => $payment->id]
|
|
),
|
|
]);
|
|
}
|
|
|
|
public function appointmentConfirmed(Appointment $a): bool
|
|
{
|
|
$company = $this->companyFor($a);
|
|
$client = $a->client;
|
|
if (! $client) return false;
|
|
|
|
return $this->dispatch($company, $client, 'appointment', [
|
|
'telegram' => fn () => $this->tgAppointmentConfirmed($a, $company, $client),
|
|
'email' => fn () => $this->emailSafe(
|
|
fn () => Mail::to($client->email)->send(new AppointmentConfirmedMail($a, $company)),
|
|
'appointmentConfirmed', ['appt' => $a->id]
|
|
),
|
|
]);
|
|
}
|
|
|
|
public function serviceReminder(Vehicle $v, string $type = 'general', ?string $note = null): bool
|
|
{
|
|
$company = $this->companyFor($v);
|
|
$client = $v->client;
|
|
if (! $client) return false;
|
|
|
|
return $this->dispatch($company, $client, 'reminder', [
|
|
'telegram' => fn () => $this->tgServiceReminder($v, $type, $note, $company, $client),
|
|
'email' => fn () => $this->emailSafe(
|
|
fn () => Mail::to($client->email)->send(new ServiceReminderMail($v, $type, $note, $company)),
|
|
'serviceReminder', ['vehicle' => $v->id]
|
|
),
|
|
]);
|
|
}
|
|
|
|
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 ─────────────────────────────────────────
|
|
|
|
/**
|
|
* @param array<string, callable(): bool> $senders channel-key → sender callback
|
|
* @return bool Returns the channel name that delivered, or null on full miss.
|
|
*/
|
|
protected function dispatch(Company $company, Client $client, string $key, array $senders): bool
|
|
{
|
|
$any = false;
|
|
foreach ($this->channelsFor($company, $client, $key) as $channel) {
|
|
if (! isset($senders[$channel])) continue;
|
|
try {
|
|
if (($senders[$channel])() === true) {
|
|
$any = true;
|
|
// Try only one channel — first that succeeds is enough.
|
|
break;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning("notify.{$key} {$channel} threw", ['err' => $e->getMessage()]);
|
|
}
|
|
}
|
|
return $any;
|
|
}
|
|
|
|
/**
|
|
* Resolve which channels to try and in what order, applying per-client
|
|
* preference if set, otherwise the tenant default.
|
|
*/
|
|
protected function channelsFor(Company $company, Client $client, string $key): array
|
|
{
|
|
$prefs = (array) ($client->notify_prefs ?? []);
|
|
|
|
// Per-client list takes precedence.
|
|
if (! empty($prefs)) {
|
|
$candidates = array_values(array_intersect(['telegram', 'email'], $prefs));
|
|
} else {
|
|
$candidates = ['telegram', 'email'];
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
$candidates,
|
|
fn (string $ch) => $this->channelEnabled($company, $client, $key, $ch)
|
|
));
|
|
}
|
|
|
|
protected function channelEnabled(Company $company, Client $client, string $key, string $channel): bool
|
|
{
|
|
$notify = (array) data_get($company->settings, 'notify', []);
|
|
// Tenant-level: notify.{type}.{channel} — accept legacy boolean notify.{type}=true as email-only flag.
|
|
$tenantValue = $notify[$key] ?? null;
|
|
if (is_bool($tenantValue)) {
|
|
// legacy: email default true, telegram default true if bot configured
|
|
if ($channel === 'email') $tenantValueEnabled = $tenantValue;
|
|
elseif ($channel === 'telegram') $tenantValueEnabled = $tenantValue && (bool) $this->telegram->tokenFor($company);
|
|
else $tenantValueEnabled = false;
|
|
} elseif (is_array($tenantValue)) {
|
|
$tenantValueEnabled = (bool) ($tenantValue[$channel] ?? true);
|
|
} else {
|
|
$tenantValueEnabled = true;
|
|
}
|
|
|
|
if (! $tenantValueEnabled) return false;
|
|
|
|
// Per-client field presence
|
|
if ($channel === 'telegram') {
|
|
return ! empty($client->telegram_chat_id) && (bool) $this->telegram->tokenFor($company);
|
|
}
|
|
if ($channel === 'email') {
|
|
return ! empty($client->email);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ─── Telegram message builders ────────────────────────────────
|
|
|
|
protected function tgWorkOrderReady(WorkOrder $wo, Company $company, Client $client): bool
|
|
{
|
|
$brand = htmlspecialchars($company->display_name ?? $company->name);
|
|
$no = htmlspecialchars((string) $wo->number);
|
|
$plate = htmlspecialchars((string) ($wo->vehicle->plate ?? ''));
|
|
$text = "✅ <b>Mașina e gata de ridicat</b>\n"
|
|
. "Fișa #{$no} · {$plate}\n"
|
|
. "Total: <b>" . number_format((float) $wo->total, 2) . " " . ($company->settings['currency'] ?? 'MDL') . "</b>\n\n"
|
|
. "🔗 Detalii: " . $wo->trackingUrl() . "\n\n"
|
|
. "{$brand}";
|
|
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
|
}
|
|
|
|
protected function tgPaymentReceived(Payment $payment, Company $company, Client $client): bool
|
|
{
|
|
$amt = number_format((float) $payment->amount, 2);
|
|
$cur = $company->settings['currency'] ?? 'MDL';
|
|
$text = "💳 <b>Plată primită</b>\n"
|
|
. "Suma: <b>{$amt} {$cur}</b>\n"
|
|
. "Mulțumim!";
|
|
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
|
}
|
|
|
|
protected function tgAppointmentConfirmed(Appointment $a, Company $company, Client $client): bool
|
|
{
|
|
$when = $a->starts_at?->isoFormat('D MMM YYYY, HH:mm') ?? '?';
|
|
$text = "📅 <b>Programare confirmată</b>\n"
|
|
. "Data: <b>{$when}</b>\n"
|
|
. ($a->vehicle?->plate ? "Auto: " . htmlspecialchars($a->vehicle->plate) . "\n" : '')
|
|
. "Te așteptăm.";
|
|
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
|
}
|
|
|
|
protected function tgServiceReminder(Vehicle $v, string $type, ?string $note, Company $company, Client $client): bool
|
|
{
|
|
$brand = htmlspecialchars($company->display_name ?? $company->name);
|
|
$plate = htmlspecialchars((string) ($v->plate ?? ''));
|
|
$text = "🔧 <b>Reminder service</b>\n"
|
|
. "{$brand}: " . htmlspecialchars($v->make . ' ' . $v->model) . " · {$plate}\n"
|
|
. ($note ?: 'A trecut ceva timp de la ultima vizită — recomandăm o verificare.');
|
|
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────
|
|
|
|
protected function emailSafe(callable $fn, string $tag, array $ctx = []): bool
|
|
{
|
|
try {
|
|
$fn();
|
|
return true;
|
|
} catch (\Throwable $e) {
|
|
Log::warning("{$tag} mail failed", $ctx + ['err' => $e->getMessage()]);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
protected function companyFor($model): Company
|
|
{
|
|
return Company::withoutGlobalScopes()->findOrFail($model->company_id);
|
|
}
|
|
}
|