Initial Ladill Queue release — enterprise QMS standalone app.
Deploy Ladill Queue / deploy (push) Successful in 56s
Deploy Ladill Queue / deploy (push) Successful in 56s
Phases 1–6: tickets, counters, displays, appointments, workflows, rules, analytics, reports, feedback, admin, device API, and Gitea deploy workflow for queue.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||
* platform; CRM only consumes it. Amounts are integer minor units; the user is
|
||||
* identified by public_id. Authenticates with the per-consumer config('billing.api_key').
|
||||
*/
|
||||
class BillingClient
|
||||
{
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('billing.api_url'), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return (string) (config('billing.api_key') ?? '');
|
||||
}
|
||||
|
||||
public function balanceMinor(string $publicId): int
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
|
||||
->get($this->base().'/balance', ['user' => $publicId]);
|
||||
$res->throw();
|
||||
|
||||
return (int) ($res->json('balance_minor') ?? 0);
|
||||
}
|
||||
|
||||
public function canAfford(string $publicId, int $amountMinor): bool
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
|
||||
->get($this->base().'/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor]);
|
||||
$res->throw();
|
||||
|
||||
return (bool) ($res->json('affordable') ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit the wallet. Returns true on success, false on insufficient balance
|
||||
* (HTTP 402). Idempotent by $reference.
|
||||
*/
|
||||
public function debit(string $publicId, int $amountMinor, string $source, string $reference, ?string $description = null): bool
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
|
||||
'user' => $publicId,
|
||||
'amount_minor' => $amountMinor,
|
||||
'service' => (string) config('billing.service', 'crm'),
|
||||
'source' => $source,
|
||||
'reference' => $reference,
|
||||
'description' => $description,
|
||||
], static fn ($v) => $v !== null));
|
||||
|
||||
if ($res->status() === 402) {
|
||||
return false;
|
||||
}
|
||||
$res->throw();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\CrmPurchase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OneTimePurchaseService
|
||||
{
|
||||
public function __construct(private readonly BillingClient $billing)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function product(string $productKey): ?array
|
||||
{
|
||||
$product = config('crm_products.'.$productKey);
|
||||
|
||||
return is_array($product) ? $product : null;
|
||||
}
|
||||
|
||||
public function hasPurchased(string $ownerRef, string $productKey): bool
|
||||
{
|
||||
return CrmPurchase::has($ownerRef, $productKey);
|
||||
}
|
||||
|
||||
public function priceMinor(string $productKey): int
|
||||
{
|
||||
return (int) ($this->product($productKey)['price_minor'] ?? 0);
|
||||
}
|
||||
|
||||
public function purchase(string $ownerRef, string $productKey): bool
|
||||
{
|
||||
if ($this->hasPurchased($ownerRef, $productKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$product = $this->product($productKey);
|
||||
if (! $product) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$costMinor = (int) ($product['price_minor'] ?? 0);
|
||||
|
||||
if ($costMinor > 0) {
|
||||
try {
|
||||
if (! $this->billing->canAfford($ownerRef, $costMinor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$charged = $this->billing->debit(
|
||||
$ownerRef,
|
||||
$costMinor,
|
||||
'purchase',
|
||||
'crm-purchase-'.$productKey.'-'.$ownerRef,
|
||||
'CRM: '.($product['name'] ?? $productKey),
|
||||
);
|
||||
|
||||
if (! $charged) {
|
||||
return false;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
CrmPurchase::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'product_key' => $productKey,
|
||||
'cost_minor' => $costMinor,
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Comms;
|
||||
|
||||
use App\Mail\ContactMessageMail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Outbound email to contacts. Uses the app's configured mailer (set MAIL_* to
|
||||
* a Ladill Bird SMTP credential in production so mail leaves as the account's
|
||||
* sender). Failures are swallowed + logged; the caller decides what to record.
|
||||
*/
|
||||
class EmailService
|
||||
{
|
||||
public function send(string $to, string $subject, string $body, ?string $fromName = null, ?string $replyTo = null): bool
|
||||
{
|
||||
if (! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($to)->send(new ContactMessageMail($subject, $body, $fromName, $replyTo));
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CRM email send failed', ['to' => $to, 'error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Comms;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Outbound SMS to contacts via Arkesel (the platform SMS provider). Low-volume,
|
||||
* best-effort transactional send — mirrors the platform's direct Arkesel calls;
|
||||
* uses the shared ARKESEL_API_KEY. Failures are swallowed + logged.
|
||||
*/
|
||||
class SmsService
|
||||
{
|
||||
public function send(string $to, string $message): bool
|
||||
{
|
||||
$apiKey = (string) config('arkesel.api_key', '');
|
||||
$sender = (string) config('arkesel.sender_id', 'Ladill');
|
||||
|
||||
$msisdn = $this->normalise($to);
|
||||
if ($apiKey === '' || $msisdn === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::timeout(20)
|
||||
->withHeaders(['api-key' => $apiKey])
|
||||
->acceptJson()
|
||||
->post(rtrim((string) config('arkesel.base_url', 'https://sms.arkesel.com'), '/').'/api/v2/sms/send', [
|
||||
'sender' => $sender,
|
||||
'message' => $message,
|
||||
'recipients' => [$msisdn],
|
||||
]);
|
||||
|
||||
return $res->successful() && ($res->json('status') === 'success');
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CRM SMS send failed', ['to' => $msisdn, 'error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalise to E.164-without-plus (e.g. 233XXXXXXXXX). */
|
||||
private function normalise(string $to): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $to) ?? '';
|
||||
$country = (string) config('arkesel.default_country_code', '233');
|
||||
|
||||
if (strlen($digits) < 9) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($digits, $country)) {
|
||||
return $digits;
|
||||
}
|
||||
|
||||
if (str_starts_with($digits, '0')) {
|
||||
return $country.substr($digits, 1);
|
||||
}
|
||||
|
||||
if (strlen($digits) === 9) {
|
||||
return $country.$digits;
|
||||
}
|
||||
|
||||
return $digits;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CrossApp;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Deal;
|
||||
use App\Support\CrmPrefillCodec;
|
||||
use App\Support\LadillAppUrl;
|
||||
|
||||
class CrossAppLinkService
|
||||
{
|
||||
public function invoiceFromDeal(Deal $deal): string
|
||||
{
|
||||
$deal->loadMissing(['customer', 'lines']);
|
||||
|
||||
$customer = $deal->customer;
|
||||
$lines = $deal->lines->map(fn ($line) => [
|
||||
'description' => $line->description,
|
||||
'quantity' => (float) $line->quantity,
|
||||
'unit_price' => number_format($line->unit_price_minor / 100, 2, '.', ''),
|
||||
])->values()->all();
|
||||
|
||||
if ($lines === [] && (int) $deal->value_minor > 0) {
|
||||
$lines = [[
|
||||
'description' => $deal->title,
|
||||
'quantity' => 1,
|
||||
'unit_price' => number_format($deal->value_minor / 100, 2, '.', ''),
|
||||
]];
|
||||
}
|
||||
|
||||
$prefill = CrmPrefillCodec::encode([
|
||||
'kind' => 'invoice',
|
||||
'crm_customer_id' => $customer?->id,
|
||||
'client_name' => $customer?->name ?: $deal->title,
|
||||
'client_email' => $customer?->email,
|
||||
'client_address' => $customer ? $this->formatAddress($customer) : null,
|
||||
'notes' => 'Invoice for CRM deal: '.$deal->title,
|
||||
'payment_enabled' => true,
|
||||
'lines' => $lines,
|
||||
]);
|
||||
|
||||
return LadillAppUrl::connect('invoice', '/invoices/create?prefill='.urlencode($prefill));
|
||||
}
|
||||
|
||||
public function merchantStorefrontFromDeal(Deal $deal): string
|
||||
{
|
||||
$deal->loadMissing('customer');
|
||||
|
||||
$amount = number_format(((int) $deal->value_minor) / 100, 2, '.', '');
|
||||
$itemName = $deal->title;
|
||||
|
||||
$prefill = CrmPrefillCodec::encode([
|
||||
'kind' => 'merchant_storefront',
|
||||
'type' => 'shop',
|
||||
'label' => 'Payment — '.$deal->title,
|
||||
'shop_title' => $deal->customer?->company ?: $deal->title,
|
||||
'sections' => [[
|
||||
'name' => 'Payment',
|
||||
'items' => [[
|
||||
'name' => $itemName,
|
||||
'description' => $deal->notes ?: 'Payment for '.$deal->title,
|
||||
'price' => $amount,
|
||||
'image_path' => '',
|
||||
]],
|
||||
]],
|
||||
'accepts_payment' => true,
|
||||
]);
|
||||
|
||||
return LadillAppUrl::connect('merchant', '/storefronts/create?prefill='.urlencode($prefill));
|
||||
}
|
||||
|
||||
public function businessQrFromContact(Customer $contact): string
|
||||
{
|
||||
$prefill = CrmPrefillCodec::encode([
|
||||
'kind' => 'qr_business',
|
||||
'type' => 'business',
|
||||
'label' => 'Card — '.$contact->name,
|
||||
'name' => $contact->company ?: $contact->name,
|
||||
'phone' => $contact->phone,
|
||||
'email' => $contact->email,
|
||||
'address' => $this->formatAddress($contact, singleLine: true),
|
||||
]);
|
||||
|
||||
return LadillAppUrl::connect('qrplus', '/qr-codes/create?prefill='.urlencode($prefill));
|
||||
}
|
||||
|
||||
public function eventsHubForContact(Customer $contact): string
|
||||
{
|
||||
$query = http_build_query(array_filter([
|
||||
'search' => $contact->company ?: $contact->name,
|
||||
]));
|
||||
|
||||
return LadillAppUrl::connect('events', '/attendees'.($query !== '' ? '?'.$query : ''));
|
||||
}
|
||||
|
||||
private function formatAddress(Customer $contact, bool $singleLine = false): ?string
|
||||
{
|
||||
$parts = array_filter([
|
||||
$contact->address_line1,
|
||||
$contact->address_line2,
|
||||
trim(implode(', ', array_filter([$contact->city, $contact->region, $contact->country]))),
|
||||
]);
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $singleLine
|
||||
? implode(', ', $parts)
|
||||
: implode("\n", $parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
class ServiceEventSignature
|
||||
{
|
||||
public static function sign(string $body, string $secret): string
|
||||
{
|
||||
return 'sha256='.hash_hmac('sha256', $body, $secret);
|
||||
}
|
||||
|
||||
public static function verify(string $body, string $signature, string $secret): bool
|
||||
{
|
||||
if ($secret === '' || $signature === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(self::sign($body, $secret), $signature);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\CustomerFeedback;
|
||||
use App\Models\QueueAppointment;
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class AnalyticsService
|
||||
{
|
||||
public function __construct(
|
||||
protected DashboardStats $dashboard,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function overview(string $ownerRef, int $organizationId, ?int $branchId = null): array
|
||||
{
|
||||
$stats = $this->dashboard->forOrganization($ownerRef, $organizationId, $branchId);
|
||||
|
||||
$today = Ticket::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->whereDate('issued_at', today());
|
||||
|
||||
$bySource = (clone $today)
|
||||
->selectRaw('source, COUNT(*) as total')
|
||||
->groupBy('source')
|
||||
->pluck('total', 'source')
|
||||
->all();
|
||||
|
||||
$byPriority = (clone $today)
|
||||
->selectRaw('priority, COUNT(*) as total')
|
||||
->groupBy('priority')
|
||||
->pluck('total', 'priority')
|
||||
->all();
|
||||
|
||||
$appointmentsToday = QueueAppointment::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->whereDate('scheduled_at', today())
|
||||
->count();
|
||||
|
||||
$feedbackAvg = CustomerFeedback::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->whereHas('ticket', fn ($t) => $t->where('branch_id', $branchId)))
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->avg('rating');
|
||||
|
||||
$queueLoad = ServiceQueue::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->where('is_active', true)
|
||||
->withCount(['tickets as waiting_count' => fn ($q) => $q->where('status', 'waiting')])
|
||||
->orderByDesc('waiting_count')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($q) => ['name' => $q->name, 'waiting' => $q->waiting_count])
|
||||
->all();
|
||||
|
||||
return array_merge($stats, [
|
||||
'tickets_by_source' => $bySource,
|
||||
'tickets_by_priority' => $byPriority,
|
||||
'appointments_today' => $appointmentsToday,
|
||||
'feedback_avg_30d' => round((float) $feedbackAvg, 1),
|
||||
'busiest_queues' => $queueLoad,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{date: string, issued: int, completed: int}>
|
||||
*/
|
||||
public function dailyTrend(string $ownerRef, int $organizationId, int $days = 14, ?int $branchId = null): array
|
||||
{
|
||||
$from = now()->subDays($days - 1)->startOfDay();
|
||||
|
||||
$issued = Ticket::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->where('issued_at', '>=', $from)
|
||||
->selectRaw('DATE(issued_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
$completed = Ticket::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->where('status', 'completed')
|
||||
->where('completed_at', '>=', $from)
|
||||
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
$trend = [];
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$date = $from->copy()->addDays($i)->toDateString();
|
||||
$trend[] = [
|
||||
'date' => $date,
|
||||
'issued' => (int) ($issued[$date] ?? 0),
|
||||
'completed' => (int) ($completed[$date] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $trend;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\QueueAppointment;
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\Ticket;
|
||||
|
||||
class AppointmentService
|
||||
{
|
||||
public function __construct(
|
||||
protected QueueEngine $engine,
|
||||
protected QueueNotificationService $notifications,
|
||||
) {}
|
||||
|
||||
public function checkIn(QueueAppointment $appointment, ?string $actorRef = null): Ticket
|
||||
{
|
||||
abort_unless($appointment->status === 'scheduled', 422, 'Appointment cannot be checked in.');
|
||||
|
||||
$queue = $appointment->serviceQueue ?? $this->defaultQueue($appointment);
|
||||
abort_unless($queue, 422, 'No queue configured for this appointment.');
|
||||
|
||||
$ticket = $this->engine->issueTicket($queue, $appointment->owner_ref, [
|
||||
'customer_name' => $appointment->customer_name,
|
||||
'customer_phone' => $appointment->customer_phone,
|
||||
'customer_email' => $appointment->customer_email,
|
||||
'priority' => 'appointment',
|
||||
'source' => 'appointment',
|
||||
'actor_ref' => $actorRef,
|
||||
'metadata' => ['appointment_id' => $appointment->id],
|
||||
]);
|
||||
|
||||
$appointment->update([
|
||||
'status' => 'checked_in',
|
||||
'checked_in_at' => now(),
|
||||
'ticket_id' => $ticket->id,
|
||||
'service_queue_id' => $queue->id,
|
||||
]);
|
||||
|
||||
AuditLogger::record($appointment->owner_ref, 'appointment.checked_in', $appointment->organization_id, $actorRef, QueueAppointment::class, $appointment->id);
|
||||
|
||||
$org = $appointment->organization;
|
||||
if ($org) {
|
||||
app(StaffNotifier::class)->queueEvent(
|
||||
$org,
|
||||
'Appointment checked in',
|
||||
"{$appointment->customer_name} checked in for {$appointment->scheduled_at->format('H:i')}.",
|
||||
route('qms.tickets.show', $ticket),
|
||||
'calendar',
|
||||
);
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
protected function defaultQueue(QueueAppointment $appointment): ?ServiceQueue
|
||||
{
|
||||
return ServiceQueue::owned($appointment->owner_ref)
|
||||
->where('branch_id', $appointment->branch_id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Member;
|
||||
|
||||
class AuditLogger
|
||||
{
|
||||
public static function record(
|
||||
string $ownerRef,
|
||||
string $action,
|
||||
?int $organizationId = null,
|
||||
?string $actorRef = null,
|
||||
?string $subjectType = null,
|
||||
?int $subjectId = null,
|
||||
?array $metadata = null,
|
||||
): AuditLog {
|
||||
return AuditLog::record(
|
||||
$ownerRef,
|
||||
$action,
|
||||
$organizationId,
|
||||
$actorRef,
|
||||
$subjectType,
|
||||
$subjectId,
|
||||
$metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\Ticket;
|
||||
use App\Support\DatabaseTime;
|
||||
|
||||
class DashboardStats
|
||||
{
|
||||
/**
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
public function forOrganization(string $ownerRef, int $organizationId, ?int $branchId = null): array
|
||||
{
|
||||
$ticketQuery = Ticket::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
|
||||
|
||||
$todayQuery = (clone $ticketQuery)->whereDate('issued_at', today());
|
||||
|
||||
$waiting = (clone $ticketQuery)->where('status', 'waiting')->count();
|
||||
$serving = (clone $ticketQuery)->whereIn('status', ['called', 'serving'])->count();
|
||||
$servedToday = (clone $todayQuery)->where('status', 'completed')->count();
|
||||
$noShowsToday = (clone $todayQuery)->where('status', 'no_show')->count();
|
||||
|
||||
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
|
||||
$avgWait = (clone $todayQuery)
|
||||
->where('status', 'completed')
|
||||
->whereNotNull('called_at')
|
||||
->selectRaw("AVG({$waitDiff}) as avg_wait")
|
||||
->value('avg_wait');
|
||||
|
||||
$activeBranches = Branch::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->where('is_active', true)
|
||||
->when($branchId, fn ($q) => $q->where('id', $branchId))
|
||||
->count();
|
||||
|
||||
return [
|
||||
'waiting' => $waiting,
|
||||
'serving' => $serving,
|
||||
'served_today' => $servedToday,
|
||||
'no_shows_today' => $noShowsToday,
|
||||
'avg_wait_seconds' => (int) round((float) $avgWait),
|
||||
'active_branches' => $activeBranches,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeviceService
|
||||
{
|
||||
public function findByToken(string $token): ?Device
|
||||
{
|
||||
return Device::query()
|
||||
->where('device_token', $token)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function recordHeartbeat(Device $device): void
|
||||
{
|
||||
$device->update([
|
||||
'status' => 'online',
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markStaleOffline(int $minutes = 10): int
|
||||
{
|
||||
return Device::query()
|
||||
->where('status', 'online')
|
||||
->where(function ($q) use ($minutes) {
|
||||
$q->whereNull('last_online_at')
|
||||
->orWhere('last_online_at', '<', now()->subMinutes($minutes));
|
||||
})
|
||||
->update(['status' => 'offline']);
|
||||
}
|
||||
|
||||
public function generateToken(Device $device): string
|
||||
{
|
||||
$token = Str::random(48);
|
||||
$device->update(['device_token' => $token]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\DisplayScreen;
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\Ticket;
|
||||
|
||||
class DisplayService
|
||||
{
|
||||
public function findByToken(string $token): ?DisplayScreen
|
||||
{
|
||||
return DisplayScreen::query()
|
||||
->where('access_token', $token)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function touch(DisplayScreen $screen): void
|
||||
{
|
||||
$screen->update(['last_seen_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function payload(DisplayScreen $screen): array
|
||||
{
|
||||
$queueIds = $screen->service_queue_ids ?? [];
|
||||
$queues = ServiceQueue::query()
|
||||
->whereIn('id', $queueIds)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
$nowServing = Ticket::query()
|
||||
->whereIn('service_queue_id', $queueIds)
|
||||
->whereIn('status', ['called', 'serving'])
|
||||
->with(['counter', 'serviceQueue'])
|
||||
->orderByDesc('called_at')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn (Ticket $t) => [
|
||||
'ticket_number' => $t->ticket_number,
|
||||
'queue_name' => $t->serviceQueue?->name,
|
||||
'counter' => $t->counter?->name,
|
||||
]);
|
||||
|
||||
$waiting = Ticket::query()
|
||||
->whereIn('service_queue_id', $queueIds)
|
||||
->where('status', 'waiting')
|
||||
->count();
|
||||
|
||||
$avgWait = (int) Ticket::query()
|
||||
->whereIn('service_queue_id', $queueIds)
|
||||
->where('status', 'completed')
|
||||
->whereDate('issued_at', today())
|
||||
->whereNotNull('called_at')
|
||||
->selectRaw('AVG(TIMESTAMPDIFF(SECOND, issued_at, called_at)) as avg_wait')
|
||||
->value('avg_wait');
|
||||
|
||||
return [
|
||||
'screen' => [
|
||||
'name' => $screen->name,
|
||||
'layout' => $screen->layout,
|
||||
],
|
||||
'now_serving' => $nowServing,
|
||||
'waiting_count' => $waiting,
|
||||
'estimated_wait_minutes' => $avgWait > 0 ? (int) ceil($avgWait / 60) : null,
|
||||
'queues' => $queues->map(fn (ServiceQueue $q) => [
|
||||
'name' => $q->name,
|
||||
'prefix' => $q->prefix,
|
||||
'is_paused' => $q->is_paused,
|
||||
]),
|
||||
'announcements' => app(VoiceAnnouncementService::class)->pendingForBranch((int) $screen->branch_id),
|
||||
'updated_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\Department;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OrganizationResolver
|
||||
{
|
||||
public function resolveForUser(User $user): ?Organization
|
||||
{
|
||||
$ref = $user->ownerRef();
|
||||
|
||||
$member = Member::where('user_ref', $ref)->first();
|
||||
if ($member) {
|
||||
return Organization::find($member->organization_id);
|
||||
}
|
||||
|
||||
return Organization::owned($ref)->first();
|
||||
}
|
||||
|
||||
public function forUser(User $user): Organization
|
||||
{
|
||||
return $this->resolveForUser($user)
|
||||
?? throw new \RuntimeException('No organization for user.');
|
||||
}
|
||||
|
||||
public function isOnboarded(User $user): bool
|
||||
{
|
||||
$organization = $this->resolveForUser($user);
|
||||
|
||||
return $organization !== null
|
||||
&& (bool) data_get($organization->settings, 'onboarded', false);
|
||||
}
|
||||
|
||||
public function memberFor(User $user, ?Organization $organization = null): ?Member
|
||||
{
|
||||
$organization ??= $this->resolveForUser($user);
|
||||
if (! $organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Member::where('organization_id', $organization->id)
|
||||
->where('user_ref', $user->ownerRef())
|
||||
->first();
|
||||
}
|
||||
|
||||
public function ensureOwnerMember(User $user, Organization $organization): Member
|
||||
{
|
||||
return Member::firstOrCreate(
|
||||
[
|
||||
'organization_id' => $organization->id,
|
||||
'user_ref' => $user->ownerRef(),
|
||||
],
|
||||
[
|
||||
'owner_ref' => $user->ownerRef(),
|
||||
'role' => 'org_admin',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function completeOnboarding(User $user, array $data): Organization
|
||||
{
|
||||
$ref = $user->ownerRef();
|
||||
|
||||
$organization = Organization::create([
|
||||
'owner_ref' => $ref,
|
||||
'name' => $data['organization_name'],
|
||||
'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6),
|
||||
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
|
||||
'settings' => [
|
||||
'onboarded' => true,
|
||||
'industry' => $data['industry'] ?? 'custom',
|
||||
'appointment_mode' => $data['appointment_mode'] ?? 'hybrid',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->ensureOwnerMember($user, $organization);
|
||||
|
||||
$branch = Branch::create([
|
||||
'owner_ref' => $ref,
|
||||
'organization_id' => $organization->id,
|
||||
'name' => $data['branch_name'],
|
||||
'address' => $data['branch_address'] ?? null,
|
||||
'phone' => $data['branch_phone'] ?? null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
Department::create([
|
||||
'owner_ref' => $ref,
|
||||
'branch_id' => $branch->id,
|
||||
'name' => 'General Reception',
|
||||
'type' => 'reception',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id);
|
||||
AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id);
|
||||
|
||||
return $organization;
|
||||
}
|
||||
|
||||
public function branchScope(?Member $member): ?int
|
||||
{
|
||||
if ($member === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($member->role, ['super_admin', 'org_admin', 'supervisor'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $member->branch_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Organization;
|
||||
|
||||
class PlanService
|
||||
{
|
||||
public function plan(Organization $organization): string
|
||||
{
|
||||
return (string) data_get($organization->settings, 'plan', 'free');
|
||||
}
|
||||
|
||||
public function isPro(Organization $organization): bool
|
||||
{
|
||||
return $this->plan($organization) === 'pro';
|
||||
}
|
||||
|
||||
public function maxBranches(Organization $organization): ?int
|
||||
{
|
||||
return config('qms.plans.'.$this->plan($organization).'.max_branches');
|
||||
}
|
||||
|
||||
public function maxQueues(Organization $organization): ?int
|
||||
{
|
||||
return config('qms.plans.'.$this->plan($organization).'.max_queues');
|
||||
}
|
||||
|
||||
public function canAddBranch(Organization $organization, int $currentCount): bool
|
||||
{
|
||||
$max = $this->maxBranches($organization);
|
||||
|
||||
return $max === null || $currentCount < $max;
|
||||
}
|
||||
|
||||
public function canAddQueue(Organization $organization, int $currentCount): bool
|
||||
{
|
||||
$max = $this->maxQueues($organization);
|
||||
|
||||
return $max === null || $currentCount < $max;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Member;
|
||||
|
||||
class QmsPermissions
|
||||
{
|
||||
/** @var array<string, list<string>> */
|
||||
protected array $roleAbilities = [
|
||||
'super_admin' => ['*'],
|
||||
'org_admin' => ['*'],
|
||||
'branch_manager' => [
|
||||
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
|
||||
'counters.view', 'counters.manage', 'console.use', 'displays.view', 'displays.manage',
|
||||
'appointments.view', 'appointments.manage', 'workflows.view', 'workflows.manage',
|
||||
'rules.view', 'rules.manage', 'reports.view', 'reports.export', 'feedback.view',
|
||||
'admin.branches.view', 'admin.branches.manage', 'admin.departments.view', 'admin.departments.manage',
|
||||
'admin.members.view', 'admin.members.manage', 'settings.view', 'settings.manage', 'audit.view', 'audit.export',
|
||||
],
|
||||
'receptionist' => [
|
||||
'dashboard.view', 'queues.view', 'tickets.view', 'tickets.issue',
|
||||
'appointments.view', 'appointments.manage',
|
||||
],
|
||||
'queue_operator' => [
|
||||
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
|
||||
'console.use', 'displays.view',
|
||||
],
|
||||
'counter_staff' => [
|
||||
'dashboard.view', 'queues.view', 'tickets.view', 'console.use',
|
||||
],
|
||||
'department_manager' => [
|
||||
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
|
||||
'counters.view', 'console.use', 'reports.view',
|
||||
],
|
||||
'security' => [
|
||||
'dashboard.view', 'tickets.view',
|
||||
],
|
||||
'supervisor' => [
|
||||
'dashboard.view', 'queues.view', 'tickets.view', 'tickets.manage',
|
||||
'counters.view', 'console.use', 'reports.view', 'reports.export',
|
||||
'feedback.view', 'audit.view', 'audit.export',
|
||||
],
|
||||
];
|
||||
|
||||
public function can(?Member $member, string $ability): bool
|
||||
{
|
||||
if ($member === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$abilities = $this->roleAbilities[$member->role] ?? [];
|
||||
|
||||
if (in_array('*', $abilities, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($ability, $abilities, true);
|
||||
}
|
||||
|
||||
public function isAdmin(?Member $member): bool
|
||||
{
|
||||
return $member !== null && in_array($member->role, ['super_admin', 'org_admin'], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Counter;
|
||||
use App\Models\Organization;
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketEvent;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class QueueEngine
|
||||
{
|
||||
public function issueTicket(
|
||||
ServiceQueue $queue,
|
||||
string $ownerRef,
|
||||
array $attributes = [],
|
||||
): Ticket {
|
||||
return DB::transaction(function () use ($queue, $ownerRef, $attributes) {
|
||||
$queue = ServiceQueue::query()->lockForUpdate()->findOrFail($queue->id);
|
||||
|
||||
if ($queue->is_paused || ! $queue->is_active) {
|
||||
throw new \RuntimeException('Queue is not accepting tickets.');
|
||||
}
|
||||
|
||||
$sourceQueueId = $queue->id;
|
||||
[$queue, $attributes] = app(QueueRuleService::class)->apply($queue, $attributes);
|
||||
app(WorkflowService::class)->attachOnIssue($queue, $attributes);
|
||||
|
||||
if ($queue->id !== $sourceQueueId) {
|
||||
$queue = ServiceQueue::query()->lockForUpdate()->findOrFail($queue->id);
|
||||
}
|
||||
|
||||
if ($queue->is_paused || ! $queue->is_active) {
|
||||
throw new \RuntimeException('Queue is not accepting tickets.');
|
||||
}
|
||||
|
||||
if ($queue->max_capacity) {
|
||||
$waiting = Ticket::query()
|
||||
->where('service_queue_id', $queue->id)
|
||||
->whereIn('status', ['waiting', 'called', 'serving', 'on_hold'])
|
||||
->count();
|
||||
if ($waiting >= $queue->max_capacity) {
|
||||
throw new \RuntimeException('Queue has reached maximum capacity.');
|
||||
}
|
||||
}
|
||||
|
||||
$queue->increment('ticket_sequence');
|
||||
$queue->refresh();
|
||||
|
||||
$number = sprintf('%s%03d', $queue->prefix, $queue->ticket_sequence);
|
||||
$waitingCount = Ticket::query()
|
||||
->where('service_queue_id', $queue->id)
|
||||
->where('status', 'waiting')
|
||||
->count();
|
||||
$estimatedWait = $waitingCount * (int) $queue->avg_service_seconds;
|
||||
|
||||
$ticket = Ticket::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'organization_id' => $queue->organization_id,
|
||||
'branch_id' => $queue->branch_id,
|
||||
'service_queue_id' => $queue->id,
|
||||
'ticket_number' => $number,
|
||||
'status' => 'waiting',
|
||||
'priority' => $attributes['priority'] ?? 'walk_in',
|
||||
'position' => $waitingCount + 1,
|
||||
'customer_name' => $attributes['customer_name'] ?? null,
|
||||
'customer_phone' => $attributes['customer_phone'] ?? null,
|
||||
'customer_email' => $attributes['customer_email'] ?? null,
|
||||
'source' => $attributes['source'] ?? 'kiosk',
|
||||
'barcode' => $attributes['barcode'] ?? Str::upper(Str::random(10)),
|
||||
'estimated_wait_seconds' => $estimatedWait,
|
||||
'issued_at' => now(),
|
||||
'metadata' => $attributes['metadata'] ?? null,
|
||||
]);
|
||||
|
||||
$this->recordEvent($ticket, 'issued', $attributes['actor_ref'] ?? null);
|
||||
|
||||
AuditLogger::record(
|
||||
$ownerRef,
|
||||
'ticket.issued',
|
||||
$queue->organization_id,
|
||||
$attributes['actor_ref'] ?? null,
|
||||
Ticket::class,
|
||||
$ticket->id,
|
||||
['ticket_number' => $number, 'queue' => $queue->name],
|
||||
);
|
||||
|
||||
$ticket = $ticket->fresh(['serviceQueue', 'branch']);
|
||||
app(QueueNotificationService::class)->ticketIssued($ticket);
|
||||
|
||||
return $ticket;
|
||||
});
|
||||
}
|
||||
|
||||
public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket
|
||||
{
|
||||
$ticket = $this->nextWaitingTicket($queue);
|
||||
if (! $ticket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->callTicket($ticket, $counter, $actorRef);
|
||||
}
|
||||
|
||||
public function callTicket(Ticket $ticket, Counter $counter, ?string $actorRef = null): Ticket
|
||||
{
|
||||
$ticket->update([
|
||||
'status' => 'called',
|
||||
'counter_id' => $counter->id,
|
||||
'called_at' => now(),
|
||||
]);
|
||||
|
||||
$counter->update(['status' => 'busy']);
|
||||
$this->recordEvent($ticket, 'called', $actorRef, $counter->id);
|
||||
$this->audit($ticket, 'ticket.called', $actorRef, ['counter_id' => $counter->id]);
|
||||
|
||||
app(VoiceAnnouncementService::class)->announceCalled($ticket, $counter);
|
||||
|
||||
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
|
||||
app(QueueNotificationService::class)->ticketCalled($ticket);
|
||||
$this->notifyWaitingPositions($ticket->serviceQueue);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
public function recall(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||
{
|
||||
$ticket->load(['serviceQueue', 'counter']);
|
||||
$this->recordEvent($ticket, 'recalled', $actorRef, $ticket->counter_id);
|
||||
$this->audit($ticket, 'ticket.recalled', $actorRef);
|
||||
|
||||
if ($ticket->counter) {
|
||||
app(VoiceAnnouncementService::class)->announceCalled($ticket, $ticket->counter);
|
||||
}
|
||||
|
||||
return $ticket->fresh(['serviceQueue', 'counter']);
|
||||
}
|
||||
|
||||
public function startServing(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||
{
|
||||
$ticket->update([
|
||||
'status' => 'serving',
|
||||
'serving_started_at' => now(),
|
||||
]);
|
||||
$this->recordEvent($ticket, 'serving', $actorRef, $ticket->counter_id);
|
||||
app(ServiceSessionTracker::class)->start($ticket, $actorRef);
|
||||
|
||||
return $ticket->fresh();
|
||||
}
|
||||
|
||||
public function hold(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||
{
|
||||
$ticket->update(['status' => 'on_hold']);
|
||||
$this->recordEvent($ticket, 'held', $actorRef, $ticket->counter_id);
|
||||
$this->audit($ticket, 'ticket.held', $actorRef);
|
||||
|
||||
return $ticket->fresh();
|
||||
}
|
||||
|
||||
public function skip(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||
{
|
||||
$ticket->update([
|
||||
'status' => 'waiting',
|
||||
'counter_id' => null,
|
||||
'called_at' => null,
|
||||
]);
|
||||
$this->recordEvent($ticket, 'skipped', $actorRef);
|
||||
$this->audit($ticket, 'ticket.skipped', $actorRef);
|
||||
|
||||
return $ticket->fresh();
|
||||
}
|
||||
|
||||
public function completeTicket(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||
{
|
||||
$ticket->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
if ($ticket->counter_id) {
|
||||
Counter::where('id', $ticket->counter_id)->update(['status' => 'available']);
|
||||
}
|
||||
|
||||
$this->recordEvent($ticket, 'completed', $actorRef, $ticket->counter_id);
|
||||
$this->audit($ticket, 'ticket.completed', $actorRef);
|
||||
app(ServiceSessionTracker::class)->endForTicket($ticket);
|
||||
|
||||
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
|
||||
app(QueueNotificationService::class)->ticketCompleted($ticket);
|
||||
app(WorkflowService::class)->advanceAfterCompletion($ticket);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
public function markNoShow(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||
{
|
||||
$ticket->update([
|
||||
'status' => 'no_show',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
if ($ticket->counter_id) {
|
||||
Counter::where('id', $ticket->counter_id)->update(['status' => 'available']);
|
||||
}
|
||||
|
||||
$this->recordEvent($ticket, 'no_show', $actorRef, $ticket->counter_id);
|
||||
$this->audit($ticket, 'ticket.no_show', $actorRef);
|
||||
|
||||
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
|
||||
app(QueueNotificationService::class)->ticketMissed($ticket);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
public function cancel(Ticket $ticket, ?string $actorRef = null): Ticket
|
||||
{
|
||||
$ticket->update(['status' => 'cancelled', 'completed_at' => now()]);
|
||||
$this->recordEvent($ticket, 'cancelled', $actorRef, $ticket->counter_id);
|
||||
$this->audit($ticket, 'ticket.cancelled', $actorRef);
|
||||
|
||||
return $ticket->fresh();
|
||||
}
|
||||
|
||||
public function transfer(
|
||||
Ticket $ticket,
|
||||
ServiceQueue $toQueue,
|
||||
?Counter $counter = null,
|
||||
?string $reason = null,
|
||||
?string $actorRef = null,
|
||||
): Ticket {
|
||||
$fromQueueId = $ticket->service_queue_id;
|
||||
|
||||
$ticket->update([
|
||||
'service_queue_id' => $toQueue->id,
|
||||
'branch_id' => $toQueue->branch_id,
|
||||
'status' => 'waiting',
|
||||
'counter_id' => null,
|
||||
'called_at' => null,
|
||||
'serving_started_at' => null,
|
||||
]);
|
||||
|
||||
DB::table('queue_ticket_transfers')->insert([
|
||||
'owner_ref' => $ticket->owner_ref,
|
||||
'ticket_id' => $ticket->id,
|
||||
'from_service_queue_id' => $fromQueueId,
|
||||
'to_service_queue_id' => $toQueue->id,
|
||||
'from_counter_id' => $counter?->id,
|
||||
'reason' => $reason,
|
||||
'transferred_by' => $actorRef,
|
||||
'transferred_at' => now(),
|
||||
]);
|
||||
|
||||
$this->recordEvent($ticket, 'transferred', $actorRef, $counter?->id, [
|
||||
'to_queue_id' => $toQueue->id,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
$this->audit($ticket, 'ticket.transferred', $actorRef, ['to_queue' => $toQueue->name]);
|
||||
|
||||
return $ticket->fresh(['serviceQueue']);
|
||||
}
|
||||
|
||||
public function pauseQueue(ServiceQueue $queue, ?string $actorRef = null): ServiceQueue
|
||||
{
|
||||
$queue->update(['is_paused' => true]);
|
||||
AuditLogger::record($queue->owner_ref, 'service_queue.paused', $queue->organization_id, $actorRef, ServiceQueue::class, $queue->id);
|
||||
|
||||
$org = Organization::find($queue->organization_id);
|
||||
if ($org) {
|
||||
app(StaffNotifier::class)->queueEvent(
|
||||
$org,
|
||||
'Queue paused',
|
||||
"{$queue->name} is no longer accepting tickets.",
|
||||
route('qms.queues.show', $queue),
|
||||
'pause',
|
||||
);
|
||||
}
|
||||
|
||||
return $queue->fresh();
|
||||
}
|
||||
|
||||
public function resumeQueue(ServiceQueue $queue, ?string $actorRef = null): ServiceQueue
|
||||
{
|
||||
$queue->update(['is_paused' => false]);
|
||||
AuditLogger::record($queue->owner_ref, 'service_queue.resumed', $queue->organization_id, $actorRef, ServiceQueue::class, $queue->id);
|
||||
|
||||
return $queue->fresh();
|
||||
}
|
||||
|
||||
public function delayArrival(Ticket $ticket, int $minutes, ?string $actorRef = null): Ticket
|
||||
{
|
||||
$metadata = $ticket->metadata ?? [];
|
||||
$metadata['delayed_until'] = now()->addMinutes($minutes)->toIso8601String();
|
||||
$ticket->update(['metadata' => $metadata]);
|
||||
$this->recordEvent($ticket, 'delayed', $actorRef, null, ['minutes' => $minutes]);
|
||||
|
||||
return $ticket->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Ticket>
|
||||
*/
|
||||
public function waitingTickets(ServiceQueue $queue, int $limit = 50): array
|
||||
{
|
||||
return Ticket::query()
|
||||
->where('service_queue_id', $queue->id)
|
||||
->where('status', 'waiting')
|
||||
->orderByRaw($this->priorityOrderSql())
|
||||
->orderBy('issued_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function nextWaitingTicket(ServiceQueue $queue): ?Ticket
|
||||
{
|
||||
return Ticket::query()
|
||||
->where('service_queue_id', $queue->id)
|
||||
->where('status', 'waiting')
|
||||
->orderByRaw($this->priorityOrderSql())
|
||||
->orderBy('issued_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
protected function priorityOrderSql(): string
|
||||
{
|
||||
$priorityOrder = array_keys(config('qms.ticket_priorities'));
|
||||
|
||||
if (\Illuminate\Support\Facades\DB::connection()->getDriverName() === 'sqlite') {
|
||||
$cases = collect($priorityOrder)
|
||||
->map(fn ($p, $i) => "WHEN priority = '{$p}' THEN {$i}")
|
||||
->implode(' ');
|
||||
|
||||
return "CASE {$cases} ELSE 99 END";
|
||||
}
|
||||
|
||||
return 'FIELD(priority, '.implode(',', array_map(fn ($p) => "'{$p}'", $priorityOrder)).')';
|
||||
}
|
||||
|
||||
protected function notifyWaitingPositions(?ServiceQueue $queue): void
|
||||
{
|
||||
if (! $queue) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notifications = app(QueueNotificationService::class);
|
||||
foreach ($this->waitingTickets($queue, 20) as $position => $waitingTicket) {
|
||||
$notifications->customersAhead($waitingTicket, $position);
|
||||
}
|
||||
}
|
||||
|
||||
protected function audit(Ticket $ticket, string $action, ?string $actorRef, ?array $metadata = null): void
|
||||
{
|
||||
AuditLogger::record(
|
||||
$ticket->owner_ref,
|
||||
$action,
|
||||
$ticket->organization_id,
|
||||
$actorRef,
|
||||
Ticket::class,
|
||||
$ticket->id,
|
||||
$metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $metadata
|
||||
*/
|
||||
protected function recordEvent(
|
||||
Ticket $ticket,
|
||||
string $event,
|
||||
?string $actorRef = null,
|
||||
?int $counterId = null,
|
||||
?array $metadata = null,
|
||||
): void {
|
||||
TicketEvent::create([
|
||||
'owner_ref' => $ticket->owner_ref,
|
||||
'ticket_id' => $ticket->id,
|
||||
'event' => $event,
|
||||
'actor_ref' => $actorRef,
|
||||
'counter_id' => $counterId,
|
||||
'metadata' => $metadata,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Services\Comms\EmailService;
|
||||
use App\Services\Comms\SmsService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class QueueNotificationService
|
||||
{
|
||||
public function __construct(
|
||||
protected SmsService $sms,
|
||||
protected EmailService $email,
|
||||
) {}
|
||||
|
||||
public function ticketIssued(Ticket $ticket): void
|
||||
{
|
||||
$this->notify($ticket, 'issued', $this->message('issued', $ticket));
|
||||
}
|
||||
|
||||
public function ticketCalled(Ticket $ticket): void
|
||||
{
|
||||
$counter = $ticket->counter?->name ?? 'the counter';
|
||||
$this->notify($ticket, 'called', $this->message('called', $ticket, [':counter' => $counter]));
|
||||
}
|
||||
|
||||
public function customersAhead(Ticket $ticket, int $ahead): void
|
||||
{
|
||||
if ($ahead > 2) {
|
||||
return;
|
||||
}
|
||||
$this->notify($ticket, 'almost_ready', $this->message('almost_ready', $ticket, [':ahead' => (string) $ahead]));
|
||||
}
|
||||
|
||||
public function ticketCompleted(Ticket $ticket): void
|
||||
{
|
||||
$feedbackUrl = route('qms.feedback.show', $ticket->qr_token);
|
||||
$this->notify($ticket, 'completed', $this->message('completed', $ticket, [':feedback_url' => $feedbackUrl]));
|
||||
}
|
||||
|
||||
public function ticketMissed(Ticket $ticket): void
|
||||
{
|
||||
$this->notify($ticket, 'missed', $this->message('missed', $ticket));
|
||||
}
|
||||
|
||||
public function appointmentReminder(\App\Models\QueueAppointment $appointment): void
|
||||
{
|
||||
$message = "Reminder: your appointment at {$appointment->scheduled_at->format('M j, H:i')} is coming up. Ref: {$appointment->reference}";
|
||||
$this->sendToContact($appointment->customer_phone, $appointment->customer_email, $message);
|
||||
}
|
||||
|
||||
protected function notify(Ticket $ticket, string $event, string $message): void
|
||||
{
|
||||
if (! data_get($ticket->serviceQueue?->settings, 'notifications_enabled', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendToContact($ticket->customer_phone, $ticket->customer_email, $message);
|
||||
|
||||
Log::info('Queue notification sent', [
|
||||
'event' => $event,
|
||||
'ticket' => $ticket->ticket_number,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function sendToContact(?string $phone, ?string $email, string $message): void
|
||||
{
|
||||
if ($phone) {
|
||||
$this->sms->send($phone, $message);
|
||||
}
|
||||
if ($email) {
|
||||
$this->email->send($email, 'Ladill Queue update', $message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $replace
|
||||
*/
|
||||
protected function message(string $key, Ticket $ticket, array $replace = []): string
|
||||
{
|
||||
$template = config("qms.notification_templates.{$key}", 'Queue update for ticket :ticket.');
|
||||
$replace = array_merge([
|
||||
':ticket' => $ticket->ticket_number,
|
||||
':queue' => $ticket->serviceQueue?->name ?? 'Queue',
|
||||
], $replace);
|
||||
|
||||
return str_replace(array_keys($replace), array_values($replace), $template);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\QueueRule;
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\Ticket;
|
||||
|
||||
class QueueRuleService
|
||||
{
|
||||
/**
|
||||
* Apply active rules and return possibly modified queue + attributes.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array{0: ServiceQueue, 1: array<string, mixed>}
|
||||
*/
|
||||
public function apply(ServiceQueue $queue, array $attributes): array
|
||||
{
|
||||
$rules = QueueRule::owned($queue->owner_ref)
|
||||
->where('service_queue_id', $queue->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
match ($rule->rule_type) {
|
||||
'priority_boost' => $this->applyPriorityBoost($rule, $attributes),
|
||||
'overflow' => [$queue, $attributes] = $this->applyOverflow($queue, $rule, $attributes),
|
||||
'routing' => [$queue, $attributes] = $this->applyRouting($queue, $rule, $attributes),
|
||||
'capacity' => [$queue, $attributes] = $this->applyCapacity($queue, $rule, $attributes),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return [$queue, $attributes];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
protected function applyPriorityBoost(QueueRule $rule, array &$attributes): void
|
||||
{
|
||||
$boostTo = data_get($rule->actions, 'priority');
|
||||
$when = data_get($rule->conditions, 'source');
|
||||
|
||||
if ($boostTo && (! $when || ($attributes['source'] ?? null) === $when)) {
|
||||
$attributes['priority'] = $boostTo;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array{0: ServiceQueue, 1: array<string, mixed>}
|
||||
*/
|
||||
protected function applyOverflow(ServiceQueue $queue, QueueRule $rule, array $attributes): array
|
||||
{
|
||||
$threshold = (int) data_get($rule->conditions, 'waiting_threshold', 0);
|
||||
$overflowQueueId = data_get($rule->actions, 'overflow_queue_id');
|
||||
|
||||
if (! $overflowQueueId || $threshold <= 0) {
|
||||
return [$queue, $attributes];
|
||||
}
|
||||
|
||||
if ($this->waitingCount($queue) >= $threshold) {
|
||||
$overflow = $this->activeQueue($overflowQueueId);
|
||||
if ($overflow) {
|
||||
return [$overflow, $attributes];
|
||||
}
|
||||
}
|
||||
|
||||
return [$queue, $attributes];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array{0: ServiceQueue, 1: array<string, mixed>}
|
||||
*/
|
||||
protected function applyRouting(ServiceQueue $queue, QueueRule $rule, array $attributes): array
|
||||
{
|
||||
$targetId = data_get($rule->actions, 'target_queue_id');
|
||||
if (! $targetId) {
|
||||
return [$queue, $attributes];
|
||||
}
|
||||
|
||||
$whenSource = data_get($rule->conditions, 'source');
|
||||
$whenPriority = data_get($rule->conditions, 'priority');
|
||||
|
||||
if ($whenSource && ($attributes['source'] ?? null) !== $whenSource) {
|
||||
return [$queue, $attributes];
|
||||
}
|
||||
if ($whenPriority && ($attributes['priority'] ?? null) !== $whenPriority) {
|
||||
return [$queue, $attributes];
|
||||
}
|
||||
|
||||
$target = $this->activeQueue((int) $targetId);
|
||||
|
||||
return $target ? [$target, $attributes] : [$queue, $attributes];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array{0: ServiceQueue, 1: array<string, mixed>}
|
||||
*/
|
||||
protected function applyCapacity(ServiceQueue $queue, QueueRule $rule, array $attributes): array
|
||||
{
|
||||
$maxWaiting = (int) data_get($rule->conditions, 'max_waiting', 0);
|
||||
if ($maxWaiting <= 0) {
|
||||
return [$queue, $attributes];
|
||||
}
|
||||
|
||||
if ($this->waitingCount($queue) < $maxWaiting) {
|
||||
return [$queue, $attributes];
|
||||
}
|
||||
|
||||
$whenFull = data_get($rule->actions, 'when_full', 'reject');
|
||||
if ($whenFull === 'reject') {
|
||||
throw new \RuntimeException('Queue has reached the configured waiting limit.');
|
||||
}
|
||||
|
||||
$overflowId = data_get($rule->actions, 'overflow_queue_id');
|
||||
if ($overflowId) {
|
||||
$overflow = $this->activeQueue((int) $overflowId);
|
||||
if ($overflow) {
|
||||
return [$overflow, $attributes];
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Queue has reached the configured waiting limit.');
|
||||
}
|
||||
|
||||
protected function waitingCount(ServiceQueue $queue): int
|
||||
{
|
||||
return Ticket::query()
|
||||
->where('service_queue_id', $queue->id)
|
||||
->where('status', 'waiting')
|
||||
->count();
|
||||
}
|
||||
|
||||
protected function activeQueue(int $queueId): ?ServiceQueue
|
||||
{
|
||||
$target = ServiceQueue::find($queueId);
|
||||
|
||||
return ($target && $target->is_active && ! $target->is_paused) ? $target : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\Counter;
|
||||
use App\Models\CustomerFeedback;
|
||||
use App\Models\QueueAppointment;
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\ServiceSession;
|
||||
use App\Models\Ticket;
|
||||
use App\Support\DatabaseTime;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ReportService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function ticketsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||
{
|
||||
$base = Ticket::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->whereBetween('issued_at', [$from, $to]);
|
||||
|
||||
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
|
||||
$serviceDiff = DatabaseTime::diffSeconds('serving_started_at', 'completed_at');
|
||||
|
||||
return [
|
||||
'total_issued' => (clone $base)->count(),
|
||||
'completed' => (clone $base)->where('status', 'completed')->count(),
|
||||
'no_show' => (clone $base)->where('status', 'no_show')->count(),
|
||||
'cancelled' => (clone $base)->where('status', 'cancelled')->count(),
|
||||
'avg_wait_seconds' => (int) round((float) (clone $base)
|
||||
->where('status', 'completed')
|
||||
->whereNotNull('called_at')
|
||||
->selectRaw("AVG({$waitDiff}) as v")
|
||||
->value('v')),
|
||||
'avg_service_seconds' => (int) round((float) (clone $base)
|
||||
->where('status', 'completed')
|
||||
->whereNotNull('serving_started_at')
|
||||
->whereNotNull('completed_at')
|
||||
->selectRaw("AVG({$serviceDiff}) as v")
|
||||
->value('v')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function waitTimesReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||
{
|
||||
$hourExpr = DatabaseTime::hourOf('issued_at');
|
||||
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
|
||||
|
||||
$rows = Ticket::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->whereBetween('issued_at', [$from, $to])
|
||||
->where('status', 'completed')
|
||||
->whereNotNull('called_at')
|
||||
->selectRaw("{$hourExpr} as hour, AVG({$waitDiff}) as avg_wait")
|
||||
->groupBy('hour')
|
||||
->orderBy('hour')
|
||||
->get();
|
||||
|
||||
$peak = $rows->sortByDesc('avg_wait')->first();
|
||||
|
||||
return [
|
||||
'hourly' => $rows->map(fn ($r) => ['hour' => (int) $r->hour, 'avg_wait_seconds' => (int) round((float) $r->avg_wait)])->values()->all(),
|
||||
'peak_hour' => $peak ? (int) $peak->hour : null,
|
||||
'peak_avg_wait_seconds' => $peak ? (int) round((float) $peak->avg_wait) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function countersReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||
{
|
||||
$counters = Counter::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->withCount(['tickets as served_count' => function ($q) use ($from, $to) {
|
||||
$q->where('status', 'completed')
|
||||
->whereBetween('completed_at', [$from, $to]);
|
||||
}])
|
||||
->orderByDesc('served_count')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'counters' => $counters->map(fn ($c) => [
|
||||
'name' => $c->name,
|
||||
'served' => $c->served_count,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function appointmentsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||
{
|
||||
$base = QueueAppointment::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
||||
->whereBetween('scheduled_at', [$from, $to]);
|
||||
|
||||
return [
|
||||
'total' => (clone $base)->count(),
|
||||
'checked_in' => (clone $base)->where('status', 'checked_in')->count(),
|
||||
'completed' => (clone $base)->where('status', 'completed')->count(),
|
||||
'cancelled' => (clone $base)->where('status', 'cancelled')->count(),
|
||||
'no_show' => (clone $base)->where('status', 'no_show')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function feedbackReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||
{
|
||||
$base = CustomerFeedback::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->whereBetween('created_at', [$from, $to])
|
||||
->when($branchId, function ($q) use ($branchId) {
|
||||
$q->whereHas('ticket', fn ($t) => $t->where('branch_id', $branchId));
|
||||
});
|
||||
|
||||
$count = (clone $base)->count();
|
||||
$avgRating = (clone $base)->avg('rating');
|
||||
|
||||
return [
|
||||
'responses' => $count,
|
||||
'avg_rating' => $count ? round((float) $avgRating, 1) : 0,
|
||||
'avg_service_quality' => round((float) (clone $base)->avg('service_quality'), 1),
|
||||
'avg_wait_experience' => round((float) (clone $base)->avg('wait_experience'), 1),
|
||||
'avg_staff_professionalism' => round((float) (clone $base)->avg('staff_professionalism'), 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serviceSessionsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||
{
|
||||
$base = ServiceSession::owned($ownerRef)
|
||||
->whereBetween('started_at', [$from, $to])
|
||||
->whereHas('ticket', function ($q) use ($organizationId, $branchId) {
|
||||
$q->where('organization_id', $organizationId);
|
||||
if ($branchId) {
|
||||
$q->where('branch_id', $branchId);
|
||||
}
|
||||
});
|
||||
|
||||
$completed = (clone $base)->whereNotNull('ended_at');
|
||||
|
||||
return [
|
||||
'sessions' => (clone $base)->count(),
|
||||
'completed_sessions' => (clone $completed)->count(),
|
||||
'avg_duration_seconds' => (int) round((float) (clone $completed)->avg('duration_seconds')),
|
||||
'total_service_seconds' => (int) (clone $completed)->sum('duration_seconds'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\ServiceSession;
|
||||
use App\Models\Ticket;
|
||||
|
||||
class ServiceSessionTracker
|
||||
{
|
||||
public function start(Ticket $ticket, ?string $staffRef = null): ServiceSession
|
||||
{
|
||||
return ServiceSession::create([
|
||||
'owner_ref' => $ticket->owner_ref,
|
||||
'ticket_id' => $ticket->id,
|
||||
'counter_id' => $ticket->counter_id,
|
||||
'staff_ref' => $staffRef,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function endForTicket(Ticket $ticket): ?ServiceSession
|
||||
{
|
||||
$session = ServiceSession::query()
|
||||
->where('ticket_id', $ticket->id)
|
||||
->whereNull('ended_at')
|
||||
->latest('started_at')
|
||||
->first();
|
||||
|
||||
if (! $session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ended = now();
|
||||
$session->update([
|
||||
'ended_at' => $ended,
|
||||
'duration_seconds' => $session->started_at->diffInSeconds($ended),
|
||||
]);
|
||||
|
||||
return $session->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Qms\QueueStaffNotification;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class StaffNotifier
|
||||
{
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
public function notifyOrganization(
|
||||
Organization $organization,
|
||||
Notification $notification,
|
||||
array $roles = ['org_admin', 'branch_manager', 'supervisor'],
|
||||
): void {
|
||||
$refs = Member::query()
|
||||
->where('organization_id', $organization->id)
|
||||
->whereIn('role', $roles)
|
||||
->pluck('user_ref');
|
||||
|
||||
User::query()
|
||||
->whereIn('public_id', $refs)
|
||||
->each(fn (User $user) => $user->notify($notification));
|
||||
}
|
||||
|
||||
public function queueEvent(
|
||||
Organization $organization,
|
||||
string $title,
|
||||
string $message,
|
||||
?string $url = null,
|
||||
string $icon = 'bell',
|
||||
): void {
|
||||
$this->notifyOrganization($organization, new QueueStaffNotification($title, $message, $url, $icon));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Ticket;
|
||||
|
||||
class TicketPresenter
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function toArray(Ticket $ticket, bool $includeQr = true): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $ticket->uuid,
|
||||
'ticket_number' => $ticket->ticket_number,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'position' => $ticket->position,
|
||||
'customer_name' => $ticket->customer_name,
|
||||
'customer_phone' => $ticket->customer_phone,
|
||||
'source' => $ticket->source,
|
||||
'estimated_wait_seconds' => $ticket->estimated_wait_seconds,
|
||||
'issued_at' => $ticket->issued_at?->toIso8601String(),
|
||||
'called_at' => $ticket->called_at?->toIso8601String(),
|
||||
'queue' => $ticket->serviceQueue ? [
|
||||
'uuid' => $ticket->serviceQueue->uuid,
|
||||
'name' => $ticket->serviceQueue->name,
|
||||
'prefix' => $ticket->serviceQueue->prefix,
|
||||
'color' => $ticket->serviceQueue->color,
|
||||
] : null,
|
||||
'counter' => $ticket->counter ? [
|
||||
'uuid' => $ticket->counter->uuid,
|
||||
'name' => $ticket->counter->name,
|
||||
] : null,
|
||||
'track_url' => $includeQr ? route('qms.mobile.show', $ticket->qr_token) : null,
|
||||
'barcode' => $ticket->barcode,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\Counter;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\VoiceAnnouncement;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class VoiceAnnouncementService
|
||||
{
|
||||
public function announceCalled(Ticket $ticket, Counter $counter, ?string $locale = null): VoiceAnnouncement
|
||||
{
|
||||
$locale ??= data_get($ticket->serviceQueue?->settings, 'announcement_locale', 'en');
|
||||
$message = $this->buildMessage($ticket, $counter, $locale);
|
||||
|
||||
return VoiceAnnouncement::create([
|
||||
'owner_ref' => $ticket->owner_ref,
|
||||
'organization_id' => $ticket->organization_id,
|
||||
'branch_id' => $ticket->branch_id,
|
||||
'ticket_id' => $ticket->id,
|
||||
'locale' => $locale,
|
||||
'message' => $message,
|
||||
'voice' => data_get($ticket->serviceQueue?->settings, 'voice', 'female'),
|
||||
'volume' => (int) data_get($ticket->serviceQueue?->settings, 'announcement_volume', 80),
|
||||
'repeat_count' => (int) data_get($ticket->serviceQueue?->settings, 'announcement_repeat', 1),
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function buildMessage(Ticket $ticket, Counter $counter, string $locale): string
|
||||
{
|
||||
$templates = config('qms.announcement_templates');
|
||||
$template = $templates[$locale] ?? $templates['en'];
|
||||
|
||||
return str_replace(
|
||||
[':ticket', ':counter'],
|
||||
[$ticket->ticket_number, $counter->name],
|
||||
$template,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function pendingForBranch(int $branchId, int $limit = 10): array
|
||||
{
|
||||
return VoiceAnnouncement::query()
|
||||
->where('branch_id', $branchId)
|
||||
->where('status', 'pending')
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (VoiceAnnouncement $a) => [
|
||||
'id' => $a->id,
|
||||
'message' => $a->message,
|
||||
'locale' => $a->locale,
|
||||
'voice' => $a->voice,
|
||||
'volume' => $a->volume,
|
||||
'repeat_count' => $a->repeat_count,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
public function markPlayed(VoiceAnnouncement $announcement): void
|
||||
{
|
||||
$announcement->update(['status' => 'played', 'played_at' => now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qms;
|
||||
|
||||
use App\Models\ServiceQueue;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\Workflow;
|
||||
use App\Models\WorkflowStep;
|
||||
|
||||
class WorkflowService
|
||||
{
|
||||
public function __construct(
|
||||
protected QueueEngine $engine,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function attachOnIssue(ServiceQueue $queue, array &$attributes): void
|
||||
{
|
||||
if (data_get($attributes, 'metadata.workflow_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$workflow = Workflow::query()
|
||||
->where('owner_ref', $queue->owner_ref)
|
||||
->where('organization_id', $queue->organization_id)
|
||||
->where('is_active', true)
|
||||
->when($queue->branch_id, fn ($q) => $q->where(fn ($inner) => $inner->whereNull('branch_id')->orWhere('branch_id', $queue->branch_id)))
|
||||
->whereHas('steps', fn ($q) => $q->where('service_queue_id', $queue->id))
|
||||
->first();
|
||||
|
||||
if (! $workflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
$firstStep = $workflow->steps()
|
||||
->where('service_queue_id', $queue->id)
|
||||
->orderBy('sort_order')
|
||||
->first();
|
||||
|
||||
if (! $firstStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata = $attributes['metadata'] ?? [];
|
||||
$metadata['workflow_id'] = $workflow->id;
|
||||
$metadata['workflow_step'] = $firstStep->sort_order;
|
||||
$attributes['metadata'] = $metadata;
|
||||
}
|
||||
|
||||
/** Advance completed ticket to the next workflow step queue, if configured. */
|
||||
public function advanceAfterCompletion(Ticket $ticket): ?Ticket
|
||||
{
|
||||
$workflowId = data_get($ticket->metadata, 'workflow_id');
|
||||
$stepOrder = (int) data_get($ticket->metadata, 'workflow_step', 0);
|
||||
|
||||
if (! $workflowId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workflow = Workflow::find($workflowId);
|
||||
if (! $workflow || ! $workflow->is_active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nextStep = WorkflowStep::where('workflow_id', $workflow->id)
|
||||
->where('sort_order', '>', $stepOrder)
|
||||
->orderBy('sort_order')
|
||||
->first();
|
||||
|
||||
if (! $nextStep?->service_queue_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$toQueue = ServiceQueue::find($nextStep->service_queue_id);
|
||||
if (! $toQueue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$transferred = $this->engine->transfer($ticket, $toQueue, null, 'Workflow advance', null);
|
||||
$transferred->update([
|
||||
'metadata' => array_merge($transferred->metadata ?? [], [
|
||||
'workflow_id' => $workflow->id,
|
||||
'workflow_step' => $nextStep->sort_order,
|
||||
]),
|
||||
]);
|
||||
|
||||
return $transferred;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, queue_id: int|null, sort_order: int}>
|
||||
*/
|
||||
public function industryTemplate(string $template): array
|
||||
{
|
||||
return config("qms.workflow_templates.{$template}", config('qms.workflow_templates.custom', []));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user