From 0854b431bc4e5a677e5a98e6aace01510cc672bb Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 17:21:56 +0000 Subject: [PATCH] Add service-point routing so call-next cannot steal assigned tickets. Counters gain destination/staff metadata; tickets can be pre-assigned to a service point with assigned_only queues for healthcare while shared_pool preserves generic bank/government behavior. Display and announcements expose ticket, staff, and destination clearly for Care and public boards. Co-authored-by: Cursor --- .../Controllers/Api/ConsoleApiController.php | 7 +- .../Controllers/Api/CounterController.php | 27 ++ .../Controllers/Api/DepartmentController.php | 140 ++++++++++ .../Api/ServiceQueueController.php | 31 ++- app/Http/Controllers/Api/TicketController.php | 42 ++- app/Models/Counter.php | 11 +- app/Models/Department.php | 12 +- app/Models/ServiceQueue.php | 16 ++ app/Models/Ticket.php | 7 + app/Services/Qms/DisplayService.php | 26 +- app/Services/Qms/QueueEngine.php | 101 +++++++- app/Services/Qms/TicketPresenter.php | 47 +++- app/Services/Qms/VoiceAnnouncementService.php | 17 +- ...80000_add_service_point_routing_fields.php | 55 ++++ resources/views/qms/display/public.blade.php | 7 +- routes/api.php | 4 + tests/Feature/ServicePointRoutingTest.php | 245 ++++++++++++++++++ 17 files changed, 753 insertions(+), 42 deletions(-) create mode 100644 app/Http/Controllers/Api/DepartmentController.php create mode 100644 database/migrations/2026_07_17_180000_add_service_point_routing_fields.php create mode 100644 tests/Feature/ServicePointRoutingTest.php diff --git a/app/Http/Controllers/Api/ConsoleApiController.php b/app/Http/Controllers/Api/ConsoleApiController.php index 01dc5a3..8556b43 100644 --- a/app/Http/Controllers/Api/ConsoleApiController.php +++ b/app/Http/Controllers/Api/ConsoleApiController.php @@ -40,10 +40,10 @@ class ConsoleApiController extends Controller ->latest('called_at') ->first(); - $waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) { + $waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) use ($counter) { return [$queue->uuid => array_map( fn ($t) => TicketPresenter::toArray($t), - $this->engine->waitingTickets($queue, 10), + $this->engine->waitingTickets($queue, 10, $counter), )]; }); @@ -61,6 +61,8 @@ class ConsoleApiController extends Controller 'counter' => [ 'uuid' => $counter->uuid, 'name' => $counter->name, + 'destination' => $counter->displayDestination(), + 'staff_display_name' => $counter->staff_display_name, 'status' => $counter->status, 'branch' => $counter->branch?->name, ], @@ -69,6 +71,7 @@ class ConsoleApiController extends Controller 'uuid' => $q->uuid, 'name' => $q->name, 'prefix' => $q->prefix, + 'routing_mode' => $q->routingMode(), ]), 'waiting_by_queue' => $waitingByQueue, 'transfer_queues' => $allQueues, diff --git a/app/Http/Controllers/Api/CounterController.php b/app/Http/Controllers/Api/CounterController.php index f3a5b92..b1a03fb 100644 --- a/app/Http/Controllers/Api/CounterController.php +++ b/app/Http/Controllers/Api/CounterController.php @@ -40,6 +40,9 @@ class CounterController extends Controller $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'code' => ['nullable', 'string', 'max:50'], + 'destination' => ['nullable', 'string', 'max:255'], + 'staff_ref' => ['nullable', 'string', 'max:191'], + 'staff_display_name' => ['nullable', 'string', 'max:255'], 'branch_id' => ['nullable', 'integer'], 'branch_name' => ['nullable', 'string', 'max:255'], 'department_id' => ['nullable', 'integer'], @@ -65,6 +68,15 @@ class CounterController extends Controller $existing->update([ 'name' => $validated['name'], 'code' => $validated['code'] ?? $existing->code, + 'destination' => array_key_exists('destination', $validated) + ? $validated['destination'] + : $existing->destination, + 'staff_ref' => array_key_exists('staff_ref', $validated) + ? $validated['staff_ref'] + : $existing->staff_ref, + 'staff_display_name' => array_key_exists('staff_display_name', $validated) + ? $validated['staff_display_name'] + : $existing->staff_display_name, 'branch_id' => $branch->id, 'department_id' => $validated['department_id'] ?? $existing->department_id, 'is_active' => array_key_exists('is_active', $validated) @@ -86,6 +98,9 @@ class CounterController extends Controller 'department_id' => $validated['department_id'] ?? null, 'name' => $validated['name'], 'code' => $validated['code'] ?? null, + 'destination' => $validated['destination'] ?? null, + 'staff_ref' => $validated['staff_ref'] ?? null, + 'staff_display_name' => $validated['staff_display_name'] ?? null, 'status' => 'offline', 'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true, 'settings' => $externalKey !== '' ? ['external_key' => $externalKey] : null, @@ -106,6 +121,9 @@ class CounterController extends Controller $validated = $request->validate([ 'name' => ['sometimes', 'string', 'max:255'], 'code' => ['nullable', 'string', 'max:50'], + 'destination' => ['nullable', 'string', 'max:255'], + 'staff_ref' => ['nullable', 'string', 'max:191'], + 'staff_display_name' => ['nullable', 'string', 'max:255'], 'is_active' => ['sometimes', 'boolean'], 'queue_ids' => ['nullable', 'array'], 'queue_ids.*' => ['uuid'], @@ -116,6 +134,11 @@ class CounterController extends Controller $counter->update(array_filter([ 'name' => $validated['name'] ?? null, 'code' => array_key_exists('code', $validated) ? $validated['code'] : null, + 'destination' => array_key_exists('destination', $validated) ? $validated['destination'] : null, + 'staff_ref' => array_key_exists('staff_ref', $validated) ? $validated['staff_ref'] : null, + 'staff_display_name' => array_key_exists('staff_display_name', $validated) + ? $validated['staff_display_name'] + : null, 'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : null, ], fn ($v) => $v !== null)); @@ -181,9 +204,13 @@ class CounterController extends Controller 'uuid' => $c->uuid, 'name' => $c->name, 'code' => $c->code, + 'destination' => $c->displayDestination(), + 'staff_ref' => $c->staff_ref, + 'staff_display_name' => $c->staff_display_name, 'status' => $c->status, 'is_active' => $c->is_active, 'branch' => $c->branch?->name, + 'department_id' => $c->department_id, 'external_key' => data_get($c->settings, 'external_key'), 'queues' => $c->serviceQueues->map(fn (ServiceQueue $q) => [ 'uuid' => $q->uuid, diff --git a/app/Http/Controllers/Api/DepartmentController.php b/app/Http/Controllers/Api/DepartmentController.php new file mode 100644 index 0000000..d1a88dd --- /dev/null +++ b/app/Http/Controllers/Api/DepartmentController.php @@ -0,0 +1,140 @@ +authorizeAbility($request, 'queues.view'); + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + $departments = Department::owned($owner) + ->whereHas('branch', fn ($q) => $q->where('organization_id', $organization->id)) + ->with('branch') + ->orderBy('name') + ->get() + ->map(fn (Department $d) => $this->serialize($d)); + + return response()->json(['data' => $departments]); + } + + public function store(Request $request): JsonResponse + { + $this->authorizeAbility($request, 'queues.manage'); + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['nullable', 'string', 'max:50'], + 'branch_id' => ['nullable', 'integer'], + 'branch_name' => ['nullable', 'string', 'max:255'], + 'external_key' => ['nullable', 'string', 'max:191'], + 'is_active' => ['nullable', 'boolean'], + ]); + + $branch = $this->resolveBranch($organization->id, $owner, $validated); + abort_unless($branch, 422, 'A valid branch_id or branch_name is required.'); + + $externalKey = isset($validated['external_key']) ? trim((string) $validated['external_key']) : ''; + if ($externalKey !== '') { + $existing = Department::owned($owner) + ->where('external_key', $externalKey) + ->first(); + + if ($existing) { + $existing->update([ + 'name' => $validated['name'], + 'type' => $validated['type'] ?? $existing->type, + 'branch_id' => $branch->id, + 'is_active' => array_key_exists('is_active', $validated) + ? (bool) $validated['is_active'] + : true, + ]); + AuditLogger::record($owner, 'department.updated', $organization->id, $owner, Department::class, $existing->id); + + return response()->json(['data' => $this->serialize($existing->fresh('branch'))]); + } + } + + $byName = Department::owned($owner) + ->where('branch_id', $branch->id) + ->whereRaw('LOWER(name) = ?', [strtolower($validated['name'])]) + ->first(); + + if ($byName) { + $byName->update([ + 'type' => $validated['type'] ?? $byName->type, + 'external_key' => $externalKey !== '' ? $externalKey : $byName->external_key, + 'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true, + ]); + AuditLogger::record($owner, 'department.updated', $organization->id, $owner, Department::class, $byName->id); + + return response()->json(['data' => $this->serialize($byName->fresh('branch'))]); + } + + $department = Department::create([ + 'owner_ref' => $owner, + 'branch_id' => $branch->id, + 'name' => $validated['name'], + 'type' => $validated['type'] ?? 'general', + 'external_key' => $externalKey !== '' ? $externalKey : null, + 'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true, + ]); + + AuditLogger::record($owner, 'department.created', $organization->id, $owner, Department::class, $department->id); + + return response()->json(['data' => $this->serialize($department->fresh('branch'))], 201); + } + + /** + * @param array $validated + */ + protected function resolveBranch(int $organizationId, string $owner, array $validated): ?Branch + { + if (! empty($validated['branch_id'])) { + return Branch::owned($owner) + ->where('organization_id', $organizationId) + ->where('id', (int) $validated['branch_id']) + ->first(); + } + + $branchName = trim((string) ($validated['branch_name'] ?? '')); + if ($branchName === '') { + return null; + } + + return Branch::owned($owner) + ->where('organization_id', $organizationId) + ->whereRaw('LOWER(name) = ?', [strtolower($branchName)]) + ->first(); + } + + /** + * @return array + */ + protected function serialize(Department $d): array + { + return [ + 'id' => $d->id, + 'name' => $d->name, + 'type' => $d->type, + 'external_key' => $d->external_key, + 'is_active' => $d->is_active, + 'branch' => $d->branch?->name, + 'branch_id' => $d->branch_id, + ]; + } +} diff --git a/app/Http/Controllers/Api/ServiceQueueController.php b/app/Http/Controllers/Api/ServiceQueueController.php index 3135fbb..caca460 100644 --- a/app/Http/Controllers/Api/ServiceQueueController.php +++ b/app/Http/Controllers/Api/ServiceQueueController.php @@ -50,12 +50,14 @@ class ServiceQueueController extends Controller 'max_capacity' => ['nullable', 'integer', 'min:1'], 'is_active' => ['nullable', 'boolean'], 'external_key' => ['nullable', 'string', 'max:191'], + 'routing_mode' => ['nullable', 'string', 'in:shared_pool,assigned_only'], ]); $branch = $this->resolveBranch($organization->id, $owner, $validated); abort_unless($branch, 422, 'A valid branch_id or branch_name is required.'); $externalKey = isset($validated['external_key']) ? trim((string) $validated['external_key']) : ''; + $routingMode = $validated['routing_mode'] ?? null; if ($externalKey !== '') { $existing = ServiceQueue::owned($owner) ->where('organization_id', $organization->id) @@ -63,6 +65,10 @@ class ServiceQueueController extends Controller ->first(); if ($existing) { + $settings = array_merge($existing->settings ?? [], ['external_key' => $externalKey]); + if ($routingMode) { + $settings['routing_mode'] = $routingMode; + } $existing->update([ 'name' => $validated['name'], 'description' => $validated['description'] ?? $existing->description, @@ -76,7 +82,7 @@ class ServiceQueueController extends Controller 'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true, - 'settings' => array_merge($existing->settings ?? [], ['external_key' => $externalKey]), + 'settings' => $settings, ]); AuditLogger::record($owner, 'service_queue.updated', $organization->id, $owner, ServiceQueue::class, $existing->id); @@ -96,6 +102,9 @@ class ServiceQueueController extends Controller if ($externalKey !== '') { $settings['external_key'] = $externalKey; } + if ($routingMode) { + $settings['routing_mode'] = $routingMode; + } $byName->update([ 'prefix' => $validated['prefix'], 'strategy' => $validated['strategy'] ?? $byName->strategy, @@ -114,6 +123,14 @@ class ServiceQueueController extends Controller 'Queue limit reached for current plan.', ); + $settings = []; + if ($externalKey !== '') { + $settings['external_key'] = $externalKey; + } + if ($routingMode) { + $settings['routing_mode'] = $routingMode; + } + $queue = ServiceQueue::create([ 'owner_ref' => $owner, 'organization_id' => $organization->id, @@ -127,7 +144,7 @@ class ServiceQueueController extends Controller 'avg_service_seconds' => $validated['avg_service_seconds'] ?? 300, 'max_capacity' => $validated['max_capacity'] ?? null, 'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true, - 'settings' => $externalKey !== '' ? ['external_key' => $externalKey] : null, + 'settings' => $settings !== [] ? $settings : null, ]); AuditLogger::record($owner, 'service_queue.created', $organization->id, $owner, ServiceQueue::class, $queue->id); @@ -148,8 +165,16 @@ class ServiceQueueController extends Controller 'is_active' => ['sometimes', 'boolean'], 'avg_service_seconds' => ['nullable', 'integer', 'min:60', 'max:7200'], 'max_capacity' => ['nullable', 'integer', 'min:1'], + 'routing_mode' => ['nullable', 'string', 'in:shared_pool,assigned_only'], ]); + $settings = $serviceQueue->settings ?? []; + if (! empty($validated['routing_mode'])) { + $settings['routing_mode'] = $validated['routing_mode']; + unset($validated['routing_mode']); + $validated['settings'] = $settings; + } + $serviceQueue->update($validated); AuditLogger::record( @@ -233,6 +258,8 @@ class ServiceQueueController extends Controller 'is_paused' => $q->is_paused, 'branch' => $q->branch?->name, 'external_key' => data_get($q->settings, 'external_key'), + 'routing_mode' => $q->routingMode(), + 'department_id' => $q->department_id, ]; } } diff --git a/app/Http/Controllers/Api/TicketController.php b/app/Http/Controllers/Api/TicketController.php index ca4c746..a46d300 100644 --- a/app/Http/Controllers/Api/TicketController.php +++ b/app/Http/Controllers/Api/TicketController.php @@ -58,17 +58,26 @@ class TicketController extends Controller 'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))], 'source' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_sources')))], 'metadata' => ['nullable', 'array'], + 'assigned_counter_id' => ['nullable', 'uuid'], + 'assigned_counter_uuid' => ['nullable', 'uuid'], ]); $queue = ServiceQueue::where('uuid', $validated['service_queue_id'])->firstOrFail(); $this->authorizeOwner($request, $queue); - $ticket = $this->engine->issueTicket($queue, $owner, [ - ...$validated, - 'source' => $validated['source'] ?? 'api', - 'actor_ref' => $owner, - 'metadata' => $validated['metadata'] ?? null, - ]); + try { + $ticket = $this->engine->issueTicket($queue, $owner, [ + ...$validated, + 'source' => $validated['source'] ?? 'api', + 'actor_ref' => $owner, + 'metadata' => $validated['metadata'] ?? null, + 'assigned_counter_uuid' => $validated['assigned_counter_uuid'] + ?? $validated['assigned_counter_id'] + ?? null, + ]); + } catch (\RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } return response()->json(['data' => TicketPresenter::toArray($ticket)], 201); } @@ -108,6 +117,7 @@ class TicketController extends Controller 'action' => ['required', 'string', 'in:recall,start,hold,skip,complete,no_show,cancel,delay,transfer'], 'minutes' => ['nullable', 'integer', 'min:5', 'max:120'], 'to_queue_id' => ['required_if:action,transfer', 'nullable', 'uuid'], + 'assigned_counter_id' => ['nullable', 'uuid'], 'reason' => ['nullable', 'string', 'max:500'], ]); @@ -119,9 +129,25 @@ class TicketController extends Controller ); $toQueue = ServiceQueue::where('uuid', $validated['to_queue_id'])->firstOrFail(); $this->authorizeOwner($request, $toQueue); - $result = $this->engine->transfer($ticket, $toQueue, null, $validated['reason'] ?? null, $actor); + $assignTo = null; + if (! empty($validated['assigned_counter_id'])) { + $assignTo = \App\Models\Counter::where('uuid', $validated['assigned_counter_id'])->firstOrFail(); + $this->authorizeOwner($request, $assignTo); + } + try { + $result = $this->engine->transfer( + $ticket, + $toQueue, + null, + $validated['reason'] ?? null, + $actor, + $assignTo, + ); + } catch (\RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } - return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter']))]); + return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter', 'assignedCounter']))]); } $result = match ($validated['action']) { diff --git a/app/Models/Counter.php b/app/Models/Counter.php index 605b95d..f00007e 100644 --- a/app/Models/Counter.php +++ b/app/Models/Counter.php @@ -19,7 +19,8 @@ class Counter extends Model protected $fillable = [ 'uuid', 'owner_ref', 'organization_id', 'branch_id', 'department_id', - 'name', 'code', 'status', 'settings', 'is_active', + 'name', 'code', 'destination', 'staff_ref', 'staff_display_name', + 'status', 'settings', 'is_active', ]; protected function casts(): array @@ -30,6 +31,14 @@ class Counter extends Model ]; } + /** Public display label (Room 4, Counter 2, etc.). Falls back to name. */ + public function displayDestination(): string + { + $destination = trim((string) ($this->destination ?? '')); + + return $destination !== '' ? $destination : (string) $this->name; + } + protected static function booted(): void { static::creating(function (self $counter) { diff --git a/app/Models/Department.php b/app/Models/Department.php index 309ff80..7cfd62b 100644 --- a/app/Models/Department.php +++ b/app/Models/Department.php @@ -14,7 +14,7 @@ class Department extends Model protected $table = 'queue_departments'; protected $fillable = [ - 'owner_ref', 'branch_id', 'name', 'type', 'is_active', + 'owner_ref', 'branch_id', 'name', 'type', 'external_key', 'is_active', ]; protected function casts(): array @@ -26,4 +26,14 @@ class Department extends Model { return $this->belongsTo(Branch::class, 'branch_id'); } + + public function counters(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Counter::class, 'department_id'); + } + + public function serviceQueues(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(ServiceQueue::class, 'department_id'); + } } diff --git a/app/Models/ServiceQueue.php b/app/Models/ServiceQueue.php index c83e8d4..dbd1674 100644 --- a/app/Models/ServiceQueue.php +++ b/app/Models/ServiceQueue.php @@ -67,4 +67,20 @@ class ServiceQueue extends Model return $this->belongsToMany(Counter::class, 'queue_counter_service_queue', 'service_queue_id', 'counter_id') ->withPivot('priority'); } + + /** + * shared_pool — any linked service point may pull unassigned waiting tickets. + * assigned_only — call-next only returns tickets pre-assigned to that service point. + */ + public function routingMode(): string + { + $mode = (string) data_get($this->settings, 'routing_mode', 'shared_pool'); + + return in_array($mode, ['shared_pool', 'assigned_only'], true) ? $mode : 'shared_pool'; + } + + public function usesAssignedOnlyRouting(): bool + { + return $this->routingMode() === 'assigned_only'; + } } diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index e39fdaa..a685d6f 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -18,6 +18,7 @@ class Ticket extends Model protected $fillable = [ 'uuid', 'owner_ref', 'organization_id', 'branch_id', 'service_queue_id', 'counter_id', + 'assigned_counter_id', 'ticket_number', 'status', 'priority', 'position', 'customer_name', 'customer_phone', 'customer_email', 'source', 'qr_token', 'barcode', 'estimated_wait_seconds', 'issued_at', 'called_at', 'serving_started_at', 'completed_at', 'metadata', @@ -62,6 +63,12 @@ class Ticket extends Model return $this->belongsTo(Counter::class, 'counter_id'); } + /** Service point this ticket is routed to (may differ from serving counter until called). */ + public function assignedCounter(): BelongsTo + { + return $this->belongsTo(Counter::class, 'assigned_counter_id'); + } + public function events(): HasMany { return $this->hasMany(TicketEvent::class, 'ticket_id'); diff --git a/app/Services/Qms/DisplayService.php b/app/Services/Qms/DisplayService.php index 8167ba3..7c11119 100644 --- a/app/Services/Qms/DisplayService.php +++ b/app/Services/Qms/DisplayService.php @@ -34,25 +34,35 @@ class DisplayService ->orderBy('name') ->get(); - // One active ticket per destination (counter). Older called/serving + // One active ticket per destination (service point / counter). Older called/serving // tickets at the same window must not crowd the public board. $nowServing = Ticket::query() ->whereIn('service_queue_id', $queueIds) ->whereIn('status', ['called', 'serving']) - ->with(['counter', 'serviceQueue']) + ->with(['counter', 'assignedCounter', 'serviceQueue']) ->orderByDesc('called_at') ->limit(48) ->get() ->unique(fn (Ticket $t) => $t->counter_id ? 'counter:'.$t->counter_id - : 'queue:'.$t->service_queue_id) + : ($t->assigned_counter_id + ? 'assigned:'.$t->assigned_counter_id + : 'queue:'.$t->service_queue_id)) ->take(12) ->values() - ->map(fn (Ticket $t) => [ - 'ticket_number' => $t->ticket_number, - 'queue_name' => $t->serviceQueue?->name, - 'counter' => $t->counter?->name, - ]) + ->map(function (Ticket $t) { + $point = $t->counter ?? $t->assignedCounter; + + return [ + 'ticket_number' => $t->ticket_number, + 'queue_name' => $t->serviceQueue?->name, + 'department' => $t->serviceQueue?->name, + 'counter' => $point?->displayDestination() ?? $point?->name, + 'destination' => $point?->displayDestination(), + 'staff_display_name' => $point?->staff_display_name, + 'announcement_text' => TicketPresenter::announcementText($t, $point), + ]; + }) ->all(); $waiting = Ticket::query() diff --git a/app/Services/Qms/QueueEngine.php b/app/Services/Qms/QueueEngine.php index efaf745..8dd8528 100644 --- a/app/Services/Qms/QueueEngine.php +++ b/app/Services/Qms/QueueEngine.php @@ -56,11 +56,14 @@ class QueueEngine ->count(); $estimatedWait = $waitingCount * (int) $queue->avg_service_seconds; + $assignedCounterId = $this->resolveAssignedCounterId($queue, $attributes); + $ticket = Ticket::create([ 'owner_ref' => $ownerRef, 'organization_id' => $queue->organization_id, 'branch_id' => $queue->branch_id, 'service_queue_id' => $queue->id, + 'assigned_counter_id' => $assignedCounterId, 'ticket_number' => $number, 'status' => 'waiting', 'priority' => $attributes['priority'] ?? 'walk_in', @@ -75,7 +78,9 @@ class QueueEngine 'metadata' => $attributes['metadata'] ?? null, ]); - $this->recordEvent($ticket, 'issued', $attributes['actor_ref'] ?? null); + $this->recordEvent($ticket, 'issued', $attributes['actor_ref'] ?? null, null, [ + 'assigned_counter_id' => $assignedCounterId, + ]); AuditLogger::record( $ownerRef, @@ -87,7 +92,7 @@ class QueueEngine ['ticket_number' => $number, 'queue' => $queue->name], ); - $ticket = $ticket->fresh(['serviceQueue', 'branch']); + $ticket = $ticket->fresh(['serviceQueue', 'branch', 'assignedCounter']); app(QueueNotificationService::class)->ticketIssued($ticket); return $ticket; @@ -96,7 +101,7 @@ class QueueEngine public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket { - $ticket = $this->nextWaitingTicket($queue); + $ticket = $this->nextWaitingTicket($queue, $counter); if (! $ticket) { return null; } @@ -106,9 +111,14 @@ class QueueEngine public function callTicket(Ticket $ticket, Counter $counter, ?string $actorRef = null): Ticket { + if ($ticket->assigned_counter_id && (int) $ticket->assigned_counter_id !== (int) $counter->id) { + throw new \RuntimeException('Ticket is assigned to another service point.'); + } + $ticket->update([ 'status' => 'called', 'counter_id' => $counter->id, + 'assigned_counter_id' => $ticket->assigned_counter_id ?: $counter->id, 'called_at' => now(), ]); @@ -229,8 +239,9 @@ class QueueEngine ?Counter $counter = null, ?string $reason = null, ?string $actorRef = null, + ?Counter $assignToCounter = null, ): Ticket { - return DB::transaction(function () use ($ticket, $toQueue, $counter, $reason, $actorRef) { + return DB::transaction(function () use ($ticket, $toQueue, $counter, $reason, $actorRef, $assignToCounter) { $ticket = Ticket::query()->lockForUpdate()->findOrFail($ticket->id); $toQueue = ServiceQueue::query()->lockForUpdate()->findOrFail($toQueue->id); @@ -256,6 +267,16 @@ class QueueEngine } } + $assignedCounterId = null; + if ($assignToCounter) { + if ((int) $assignToCounter->organization_id !== (int) $toQueue->organization_id) { + throw new \RuntimeException('Assigned service point is outside this organization.'); + } + $assignedCounterId = $assignToCounter->id; + } elseif ($toQueue->usesAssignedOnlyRouting()) { + throw new \RuntimeException('Target queue requires an assigned service point.'); + } + $fromQueueId = $ticket->service_queue_id; $fromCounterId = $counter?->id ?? $ticket->counter_id; $ticketNumber = $ticket->ticket_number; @@ -274,6 +295,7 @@ class QueueEngine 'position' => $waitingCount + 1, 'estimated_wait_seconds' => $estimatedWait, 'counter_id' => null, + 'assigned_counter_id' => $assignedCounterId, 'called_at' => null, 'serving_started_at' => null, 'completed_at' => null, @@ -355,26 +377,77 @@ class QueueEngine /** * @return list */ - public function waitingTickets(ServiceQueue $queue, int $limit = 50): array + public function waitingTickets(ServiceQueue $queue, int $limit = 50, ?Counter $forCounter = null): array { - return Ticket::query() - ->where('service_queue_id', $queue->id) - ->where('status', 'waiting') - ->orderByRaw($this->priorityOrderSql()) - ->orderBy('issued_at') + return $this->waitingTicketQuery($queue, $forCounter) ->limit($limit) ->get() ->all(); } - protected function nextWaitingTicket(ServiceQueue $queue): ?Ticket + protected function nextWaitingTicket(ServiceQueue $queue, ?Counter $counter = null): ?Ticket { - return Ticket::query() + return $this->waitingTicketQuery($queue, $counter)->first(); + } + + /** + * Tickets visible/callable for a service point: + * - assigned_only: only tickets assigned to this counter + * - shared_pool: unassigned tickets, or tickets assigned to this counter + * - no counter: all waiting (admin/list views) + */ + protected function waitingTicketQuery(ServiceQueue $queue, ?Counter $counter = null) + { + $query = Ticket::query() ->where('service_queue_id', $queue->id) ->where('status', 'waiting') ->orderByRaw($this->priorityOrderSql()) - ->orderBy('issued_at') - ->first(); + ->orderBy('issued_at'); + + if (! $counter) { + return $query; + } + + if ($queue->usesAssignedOnlyRouting()) { + return $query->where('assigned_counter_id', $counter->id); + } + + return $query->where(function ($q) use ($counter) { + $q->whereNull('assigned_counter_id') + ->orWhere('assigned_counter_id', $counter->id); + }); + } + + /** + * @param array $attributes + */ + protected function resolveAssignedCounterId(ServiceQueue $queue, array $attributes): ?int + { + $counter = null; + + if (! empty($attributes['assigned_counter_id'])) { + $value = $attributes['assigned_counter_id']; + $counter = is_numeric($value) + ? Counter::query()->find((int) $value) + : Counter::query()->where('uuid', (string) $value)->first(); + } elseif (! empty($attributes['assigned_counter_uuid'])) { + $counter = Counter::query()->where('uuid', (string) $attributes['assigned_counter_uuid'])->first(); + } + + if (! $counter) { + if ($queue->usesAssignedOnlyRouting()) { + throw new \RuntimeException('This queue requires an assigned service point.'); + } + + return null; + } + + if ((int) $counter->organization_id !== (int) $queue->organization_id + || (string) $counter->owner_ref !== (string) $queue->owner_ref) { + throw new \RuntimeException('Assigned service point is outside this organization.'); + } + + return (int) $counter->id; } protected function priorityOrderSql(): string diff --git a/app/Services/Qms/TicketPresenter.php b/app/Services/Qms/TicketPresenter.php index a70b27f..29bad24 100644 --- a/app/Services/Qms/TicketPresenter.php +++ b/app/Services/Qms/TicketPresenter.php @@ -11,6 +11,12 @@ class TicketPresenter */ public static function toArray(Ticket $ticket, bool $includeQr = true): array { + $ticket->loadMissing(['serviceQueue', 'counter', 'assignedCounter']); + + $serving = $ticket->counter; + $assigned = $ticket->assignedCounter; + $point = $serving ?? $assigned; + return [ 'uuid' => $ticket->uuid, 'ticket_number' => $ticket->ticket_number, @@ -28,13 +34,46 @@ class TicketPresenter 'name' => $ticket->serviceQueue->name, 'prefix' => $ticket->serviceQueue->prefix, 'color' => $ticket->serviceQueue->color, + 'routing_mode' => $ticket->serviceQueue->routingMode(), ] : null, - 'counter' => $ticket->counter ? [ - 'uuid' => $ticket->counter->uuid, - 'name' => $ticket->counter->name, - ] : null, + 'counter' => $serving ? self::serializePoint($serving) : null, + 'assigned_counter' => $assigned ? self::serializePoint($assigned) : null, + 'destination' => $point?->displayDestination(), + 'staff_display_name' => $point?->staff_display_name, + 'announcement_text' => self::announcementText($ticket, $point), 'track_url' => $includeQr ? route('qms.mobile.show', $ticket->qr_token) : null, 'barcode' => $ticket->barcode, ]; } + + /** + * @return array + */ + public static function serializePoint(\App\Models\Counter $counter): array + { + return [ + 'uuid' => $counter->uuid, + 'name' => $counter->name, + 'destination' => $counter->displayDestination(), + 'staff_ref' => $counter->staff_ref, + 'staff_display_name' => $counter->staff_display_name, + 'code' => $counter->code, + ]; + } + + public static function announcementText(Ticket $ticket, ?\App\Models\Counter $point): ?string + { + if (! $point) { + return $ticket->ticket_number; + } + + $parts = array_values(array_filter([ + $ticket->ticket_number, + $point->staff_display_name ?: null, + $ticket->serviceQueue?->name, + $point->displayDestination(), + ])); + + return implode(', ', $parts); + } } diff --git a/app/Services/Qms/VoiceAnnouncementService.php b/app/Services/Qms/VoiceAnnouncementService.php index 87ed2e1..081012b 100644 --- a/app/Services/Qms/VoiceAnnouncementService.php +++ b/app/Services/Qms/VoiceAnnouncementService.php @@ -31,10 +31,25 @@ class VoiceAnnouncementService { $templates = config('qms.announcement_templates'); $template = $templates[$locale] ?? $templates['en']; + $destination = $counter->displayDestination(); + $staff = trim((string) ($counter->staff_display_name ?? '')); + $queueName = trim((string) ($ticket->serviceQueue?->name ?? '')); + + // Prefer rich healthcare-style copy when staff is known: + // "A005, Dr. Mensah, Consultation Room 4." + if ($staff !== '') { + $parts = array_values(array_filter([ + $ticket->ticket_number, + $staff, + $queueName !== '' ? $queueName.' '.$destination : $destination, + ])); + + return implode(', ', $parts).'.'; + } return str_replace( [':ticket', ':counter'], - [$ticket->ticket_number, $counter->name], + [$ticket->ticket_number, $destination], $template, ); } diff --git a/database/migrations/2026_07_17_180000_add_service_point_routing_fields.php b/database/migrations/2026_07_17_180000_add_service_point_routing_fields.php new file mode 100644 index 0000000..c245595 --- /dev/null +++ b/database/migrations/2026_07_17_180000_add_service_point_routing_fields.php @@ -0,0 +1,55 @@ +string('destination')->nullable()->after('code'); + $table->string('staff_ref')->nullable()->index()->after('destination'); + $table->string('staff_display_name')->nullable()->after('staff_ref'); + }); + + Schema::table('queue_tickets', function (Blueprint $table) { + $table->foreignId('assigned_counter_id') + ->nullable() + ->after('counter_id') + ->constrained('queue_counters') + ->nullOnDelete(); + $table->index(['service_queue_id', 'status', 'assigned_counter_id'], 'queue_tickets_waiting_assignment_idx'); + }); + + Schema::table('queue_departments', function (Blueprint $table) { + $table->string('external_key')->nullable()->after('type'); + $table->index(['owner_ref', 'external_key']); + }); + } + + public function down(): void + { + Schema::table('queue_tickets', function (Blueprint $table) { + $table->dropIndex('queue_tickets_waiting_assignment_idx'); + $table->dropConstrainedForeignId('assigned_counter_id'); + }); + + Schema::table('queue_counters', function (Blueprint $table) { + $table->dropColumn(['destination', 'staff_ref', 'staff_display_name']); + }); + + Schema::table('queue_departments', function (Blueprint $table) { + $table->dropIndex(['owner_ref', 'external_key']); + $table->dropColumn('external_key'); + }); + } +}; diff --git a/resources/views/qms/display/public.blade.php b/resources/views/qms/display/public.blade.php index ca47d10..fdabcdb 100644 --- a/resources/views/qms/display/public.blade.php +++ b/resources/views/qms/display/public.blade.php @@ -96,7 +96,12 @@

Please proceed to -

+

+

diff --git a/routes/api.php b/routes/api.php index 243008d..b346b30 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AppointmentController; use App\Http\Controllers\Api\BranchController; use App\Http\Controllers\Api\ConsoleApiController; use App\Http\Controllers\Api\CounterController; +use App\Http\Controllers\Api\DepartmentController; use App\Http\Controllers\Api\DeviceHeartbeatController; use App\Http\Controllers\Api\IntegrationController; use App\Http\Controllers\Api\PublicQueueController; @@ -74,6 +75,9 @@ Route::middleware(['auth.service:qms', 'qms.integration'])->prefix('v1')->group( Route::get('/branches', [BranchController::class, 'index'])->name('api.service.branches.index'); Route::post('/branches', [BranchController::class, 'store'])->name('api.service.branches.store'); + Route::get('/departments', [DepartmentController::class, 'index'])->name('api.service.departments.index'); + Route::post('/departments', [DepartmentController::class, 'store'])->name('api.service.departments.store'); + Route::get('/queues', [ServiceQueueController::class, 'index'])->name('api.service.queues.index'); Route::post('/queues', [ServiceQueueController::class, 'store'])->name('api.service.queues.store'); Route::patch('/queues/{serviceQueue}', [ServiceQueueController::class, 'update'])->name('api.service.queues.update'); diff --git a/tests/Feature/ServicePointRoutingTest.php b/tests/Feature/ServicePointRoutingTest.php new file mode 100644 index 0000000..249609e --- /dev/null +++ b/tests/Feature/ServicePointRoutingTest.php @@ -0,0 +1,245 @@ + 'sp-owner', + 'name' => 'Owner', + 'email' => 'sp@example.com', + 'password' => bcrypt('password'), + ]); + $org = app(OrganizationResolver::class)->completeOnboarding($user, [ + 'organization_name' => 'Clinic', + 'industry' => 'healthcare', + 'appointment_mode' => 'hybrid', + 'branch_name' => 'Main', + 'timezone' => 'UTC', + ]); + $branch = Branch::first(); + $queue = ServiceQueue::create([ + 'owner_ref' => $user->public_id, + 'organization_id' => $org->id, + 'branch_id' => $branch->id, + 'name' => 'Consultation', + 'prefix' => 'C', + 'strategy' => 'fifo', + 'is_active' => true, + 'settings' => ['routing_mode' => 'assigned_only'], + ]); + $room4 = Counter::create([ + 'owner_ref' => $user->public_id, + 'organization_id' => $org->id, + 'branch_id' => $branch->id, + 'name' => 'Dr. Mensah', + 'destination' => 'Consultation Room 4', + 'staff_ref' => 'doc-mensah', + 'staff_display_name' => 'Dr. Mensah', + 'status' => 'available', + 'is_active' => true, + ]); + $room5 = Counter::create([ + 'owner_ref' => $user->public_id, + 'organization_id' => $org->id, + 'branch_id' => $branch->id, + 'name' => 'Dr. Okai', + 'destination' => 'Consultation Room 5', + 'staff_ref' => 'doc-okai', + 'staff_display_name' => 'Dr. Okai', + 'status' => 'available', + 'is_active' => true, + ]); + $room4->serviceQueues()->sync([$queue->id]); + $room5->serviceQueues()->sync([$queue->id]); + + return [$user, $org, $branch, $queue, $room4, $room5]; + } + + public function test_call_next_cannot_steal_ticket_assigned_to_another_service_point(): void + { + [$user, , , $queue, $room4, $room5] = $this->setUpAssignedConsultation(); + $engine = app(QueueEngine::class); + + $ticket = $engine->issueTicket($queue, $user->public_id, [ + 'customer_name' => 'Ada', + 'assigned_counter_uuid' => $room4->uuid, + ]); + + $this->assertSame($room4->id, $ticket->assigned_counter_id); + + $stolen = $engine->callNext($queue, $room5, $user->public_id); + $this->assertNull($stolen); + + $called = $engine->callNext($queue, $room4, $user->public_id); + $this->assertNotNull($called); + $this->assertSame($ticket->id, $called->id); + $this->assertSame('called', $called->status); + $this->assertSame($room4->id, $called->counter_id); + } + + public function test_assigned_only_queue_requires_service_point_on_issue(): void + { + [$user, , , $queue] = $this->setUpAssignedConsultation(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('assigned service point'); + + app(QueueEngine::class)->issueTicket($queue, $user->public_id, [ + 'customer_name' => 'No Point', + ]); + } + + public function test_display_shows_latest_per_service_point_with_destination_and_staff(): void + { + [$user, , $branch, $queue, $room4, $room5] = $this->setUpAssignedConsultation(); + $engine = app(QueueEngine::class); + + $t1 = $engine->issueTicket($queue, $user->public_id, [ + 'assigned_counter_uuid' => $room4->uuid, + 'customer_name' => 'Patient A', + ]); + $t2 = $engine->issueTicket($queue, $user->public_id, [ + 'assigned_counter_uuid' => $room5->uuid, + 'customer_name' => 'Patient B', + ]); + + $engine->callTicket($t1, $room4, $user->public_id); + $engine->callTicket($t2, $room5, $user->public_id); + + $screen = DisplayScreen::create([ + 'owner_ref' => $user->public_id, + 'organization_id' => $queue->organization_id, + 'branch_id' => $branch->id, + 'name' => 'Lobby', + 'layout' => 'standard', + 'service_queue_ids' => [$queue->id], + 'is_active' => true, + ]); + + $payload = app(DisplayService::class)->payload($screen); + + $this->assertCount(2, $payload['now_serving']); + $destinations = collect($payload['now_serving'])->pluck('destination')->all(); + $this->assertContains('Consultation Room 4', $destinations); + $this->assertContains('Consultation Room 5', $destinations); + $staff = collect($payload['now_serving'])->pluck('staff_display_name')->all(); + $this->assertContains('Dr. Mensah', $staff); + $this->assertContains('Dr. Okai', $staff); + + $mensah = collect($payload['now_serving'])->firstWhere('staff_display_name', 'Dr. Mensah'); + $this->assertStringContainsString('Dr. Mensah', (string) ($mensah['announcement_text'] ?? '')); + $this->assertStringContainsString('Consultation Room 4', (string) ($mensah['announcement_text'] ?? '')); + } + + public function test_shared_pool_still_allows_unassigned_call_next(): void + { + $user = User::create([ + 'public_id' => 'bank-owner', + 'name' => 'Bank', + 'email' => 'bank@example.com', + 'password' => bcrypt('password'), + ]); + $org = app(OrganizationResolver::class)->completeOnboarding($user, [ + 'organization_name' => 'Bank', + 'industry' => 'banking', + 'appointment_mode' => 'walk_in_only', + 'branch_name' => 'Main', + 'timezone' => 'UTC', + ]); + $branch = Branch::first(); + $queue = ServiceQueue::create([ + 'owner_ref' => $user->public_id, + 'organization_id' => $org->id, + 'branch_id' => $branch->id, + 'name' => 'Teller', + 'prefix' => 'T', + 'strategy' => 'fifo', + 'is_active' => true, + 'settings' => ['routing_mode' => 'shared_pool'], + ]); + $window = Counter::create([ + 'owner_ref' => $user->public_id, + 'organization_id' => $org->id, + 'branch_id' => $branch->id, + 'name' => 'Window 1', + 'destination' => 'Window 1', + 'status' => 'available', + 'is_active' => true, + ]); + $window->serviceQueues()->sync([$queue->id]); + + $engine = app(QueueEngine::class); + $ticket = $engine->issueTicket($queue, $user->public_id, ['customer_name' => 'Customer']); + $this->assertNull($ticket->assigned_counter_id); + + $called = $engine->callNext($queue, $window, $user->public_id); + $this->assertNotNull($called); + $this->assertSame($ticket->id, $called->id); + } + + public function test_care_api_can_issue_assigned_ticket(): void + { + config(['qms.service_api_keys.care' => 'test-care-key']); + $owner = 'care-sp-owner'; + $headers = [ + 'Authorization' => 'Bearer test-care-key', + 'Accept' => 'application/json', + ]; + + $this->postJson('/api/v1/integrations/provision', [ + 'owner' => $owner, + 'organization_name' => 'Care Clinic', + 'branch_name' => 'Main', + ], $headers)->assertSuccessful(); + + $queueUuid = $this->postJson('/api/v1/queues', [ + 'owner' => $owner, + 'name' => 'Consultation', + 'prefix' => 'C', + 'branch_name' => 'Main', + 'external_key' => 'care:dept:consultation:queue:1', + 'routing_mode' => 'assigned_only', + ], $headers)->assertCreated()->json('data.uuid'); + + $counterUuid = $this->postJson('/api/v1/counters', [ + 'owner' => $owner, + 'name' => 'Dr. Mensah', + 'destination' => 'Room 4', + 'staff_display_name' => 'Dr. Mensah', + 'branch_name' => 'Main', + 'queue_uuids' => [$queueUuid], + 'external_key' => 'care:point:consultation:practitioner:9', + ], $headers)->assertCreated()->json('data.uuid'); + + $this->postJson('/api/v1/tickets', [ + 'owner' => $owner, + 'service_queue_id' => $queueUuid, + 'customer_name' => 'Ada', + 'assigned_counter_id' => $counterUuid, + ], $headers) + ->assertCreated() + ->assertJsonPath('data.assigned_counter.uuid', $counterUuid) + ->assertJsonPath('data.destination', 'Room 4') + ->assertJsonPath('data.staff_display_name', 'Dr. Mensah'); + } +}