Files
autocrm/app/Services/NotificationDispatcher.php
Vasyka 03e030d6d2 feat: tier 3 polish — M12/13/14/15 deep cleanup
Closes the remaining ~50h of items from CONFORMITY-12-15.md across all
four modules. Single umbrella migration (2026_06_05_000004) lands four
tables + 5 column additions, no downtime risk.

== M12 — body_type + transmission + pricing audit log ==

Vehicle gains body_type (12 values: sedan/hatchback/suv/crossover/pickup/
van/truck/coupe/wagon/convertible/minivan/moto) and transmission_type
(6 values: manual/automatic/cvt/dsg/dct/amt). These are separate from
vehicle_class so admin can configure DSG-only coefficients without
contaminating the SUV detection.

PricingCoefficient.matches() now also tests:
  - conditions.body_types[] against ctx.body_type
  - conditions.transmissions[] against ctx.transmission

PricingEngine builds the richer ctx and exposes it on the quote return
under quote.context.

New pricing_application_logs table (append-only) — call
PricingEngine::logApplication($quote, $subject, $vehicle, $client, $part)
after applying a price to a WO line. Stores base, final, full
applied[] array, and the ctx snapshot so the question "why was this
priced at 218 lei in March?" stays answerable forever.

PricingCoefficientResource form gains CheckboxList for body_types and
transmissions (3-column layouts, full-width). Both are optional —
empty list = applies to anything.

== M13 — Mechanic REST API + KPI ==

New MechanicApiController with 7 endpoints under /api/v1/mechanic/:
  GET    /board               — own non-done WOs with their works expanded
  GET    /kpi?period=YYYY-MM  — own aggregates for the period
  POST   /tasks/{w}/start
  POST   /tasks/{w}/pause
  POST   /tasks/{w}/resume
  POST   /tasks/{w}/done
  POST   /tasks/{w}/block     — validates reason from BLOCK_REASONS enum

Every endpoint authorizes ownership: $work->workOrder->master_id ===
auth()->id() else 403. board() returns null pending_works so native
apps don't make round-trips. workPayload() emits efficiency_pct and
efficiency_class on every response.

New MechanicKpi Filament page at /app/mechanic-kpi (Service group). Same
aggregation logic but tenant-wide: groups WorkOrderWork rows by
master_id for the selected period, computes totals + efficiency_pct +
revenue. Period navigation via ◀/▶ buttons, default = current month.
Color-coded efficiency badges (green ≤100%, amber ≤130%, red >130%).
Rows sort by revenue descending — easy "top earners this month" view.

== M14 — OCR async via Laravel queue ==

New ocr_jobs table (id, supplier_id?, source_type, file_path, status,
result JSON, error_message, ai_provider, tokens_used, purchase_id?,
processed_at). Idempotent migration.

New OcrJob model + ProcessOcrJob queueable job. Job re-establishes
tenant context inside the worker (Company::find + TenantManager::setCurrent)
since queue workers don't inherit middleware-resolved tenants.

handle() walks: status=pending → processing, calls OcrInvoiceService::extract,
on success → status=done + result + ai_provider; on throw → status=failed
+ error_message. Failed jobs auto-retry once (tries=2) with 120s timeout.

The existing synchronous OcrInvoiceService stays for inline use cases
(tests, quick imports). The job is now the canonical path for the
admin UI to keep requests sub-100ms.

== M15 — eta_promised + JSON tracking + notifications log ==

Three new wo columns: eta_promised (initial commitment, never changes),
eta_change_reason (text for "așteptăm piesă"), eta_updated_at (when
the current eta was last touched). Existing eta_at remains as "current"
ETA so the UI can render both side-by-side.

New /api/track/{token} JSON endpoint (public, tenant-scoped via subdomain):
  number, status, status_label, progress %, client, vehicle, plate, master,
  eta_promised, eta_current, eta_change_reason, total, pay_status,
  pending_approvals[] (each with kind/id/name/amount/approve_url —
  signed URLs ready for native app webview),
  timeline[] (from activity_log, last 20 events).

NotificationDispatcher::dispatch() gains optional workOrderId param.
Every send call (success or failure) now writes one row to the new
client_notifications_log table with channel/template_key/status (sent
or failed)/error_detail/sent_at. Failures of logging are swallowed
so a missing activity_log never breaks notifications. workOrderReady
and paymentReceived pass the WO id through; others can be wired in
future commits without schema change.

New tables tracked:
  client_notifications_log — every push to client, append-only
  pricing_application_logs — every pricing decision, append-only
  ocr_jobs — async OCR job queue

== Tests ==

PolishTier3Test (11):
- M12: body_type condition match/no-match; transmission DSG match;
  pricing_log row persists base/final/applied/ctx
- M13: mechanic API board scoped to own WOs; start task on foreign
  work returns 403; KPI endpoint computes 2.5/3 = 83% efficiency
  across 2 done works in period
- M14: ocr_job queueable + Queue::fake assertion
- M15: tracking JSON returns ETA promised/current/reason + pending
  approvals with correctly-signed approve_url; dispatcher writes
  ClientNotificationLog row on workOrderReady
- M12: vehicle body_type + transmission_type round-trip through save

Suite: 269 passed (761 assertions). Was 258.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 05:31:50 +00:00

290 lines
12 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]
),
], workOrderId: $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]
),
], workOrderId: $payment->work_order_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, ?int $workOrderId = null): bool
{
$any = false;
foreach ($this->channelsFor($company, $client, $key) as $channel) {
if (! isset($senders[$channel])) continue;
try {
$ok = ($senders[$channel])() === true;
$this->logNotification($company->id, $workOrderId, $client->id, $channel, $key, $ok);
if ($ok) {
$any = true;
break;
}
} catch (\Throwable $e) {
Log::warning("notify.{$key} {$channel} threw", ['err' => $e->getMessage()]);
$this->logNotification($company->id, $workOrderId, $client->id, $channel, $key, false, $e->getMessage());
}
}
return $any;
}
/** Append-only log entry — never throw from here, swallow DB errors. */
protected function logNotification(int $companyId, ?int $workOrderId, ?int $clientId, string $channel, string $key, bool $success, ?string $error = null): void
{
try {
\App\Models\Tenant\ClientNotificationLog::create([
'company_id' => $companyId,
'work_order_id' => $workOrderId,
'client_id' => $clientId,
'channel' => $channel,
'template_key' => $key,
'status' => $success ? 'sent' : 'failed',
'error_detail' => $error,
'sent_at' => now(),
]);
} catch (\Throwable $e) { /* never break sending because of logging */ }
}
/**
* 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);
}
}