withoutMiddleware(EnsurePlatformSession::class); } /** * @return array{0: User, 1: Organization, 2: Branch, 3: ServiceQueue, 4: Counter, 5: Counter} */ protected function setUpAssignedConsultation(): array { $user = User::create([ 'public_id' => '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_web_issue_without_service_point_returns_validation_error_not_500(): void { [$user, , , $queue] = $this->setUpAssignedConsultation(); $this->actingAs($user) ->from(route('qms.tickets.create')) ->post(route('qms.tickets.store'), [ 'service_queue_id' => $queue->id, 'customer_name' => 'Walk-in', 'priority' => 'walk_in', ]) ->assertRedirect(route('qms.tickets.create')) ->assertSessionHasErrors('assigned_counter_id'); $this->assertSame(0, Ticket::query()->where('service_queue_id', $queue->id)->count()); } public function test_web_issue_with_service_point_succeeds(): void { [$user, , , $queue, $room4] = $this->setUpAssignedConsultation(); $this->actingAs($user) ->post(route('qms.tickets.store'), [ 'service_queue_id' => $queue->id, 'assigned_counter_id' => $room4->id, 'customer_name' => 'Walk-in', 'priority' => 'walk_in', ]) ->assertRedirect(); $ticket = Ticket::query()->where('service_queue_id', $queue->id)->first(); $this->assertNotNull($ticket); $this->assertSame($room4->id, $ticket->assigned_counter_id); } 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_shared_pool_call_next_is_fifo_and_one_at_a_time_per_counter(): void { $user = User::create([ 'public_id' => 'pharm-owner', 'name' => 'Pharmacy', 'email' => 'pharm@example.com', 'password' => bcrypt('password'), ]); $org = app(OrganizationResolver::class)->completeOnboarding($user, [ 'organization_name' => 'Clinic Pharmacy', 'industry' => 'custom', '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' => 'Pharmacy', 'prefix' => 'P', 'strategy' => 'fifo', 'is_active' => true, 'settings' => ['routing_mode' => 'shared_pool'], ]); $c1 = Counter::create([ 'owner_ref' => $user->public_id, 'organization_id' => $org->id, 'branch_id' => $branch->id, 'name' => 'Counter 1', 'destination' => 'Counter 1', 'status' => 'available', 'is_active' => true, ]); $c2 = Counter::create([ 'owner_ref' => $user->public_id, 'organization_id' => $org->id, 'branch_id' => $branch->id, 'name' => 'Counter 2', 'destination' => 'Counter 2', 'status' => 'available', 'is_active' => true, ]); $c1->serviceQueues()->sync([$queue->id]); $c2->serviceQueues()->sync([$queue->id]); $engine = app(QueueEngine::class); $first = $engine->issueTicket($queue, $user->public_id, ['customer_name' => 'A']); $second = $engine->issueTicket($queue->fresh(), $user->public_id, ['customer_name' => 'B']); $third = $engine->issueTicket($queue->fresh(), $user->public_id, ['customer_name' => 'C']); // Pre-assign first ticket to the "wrong" counter — shared pool must still allow FIFO claim. $first->update(['assigned_counter_id' => $c2->id]); $called1 = $engine->callNext($queue, $c1, $user->public_id); $this->assertSame($first->id, $called1->id); $this->assertSame('called', $called1->status); $this->assertSame($c1->id, $called1->counter_id); // Second Call next at same counter recalls the same patient — does not stack. $again = $engine->callNext($queue, $c1, $user->public_id); $this->assertSame($first->id, $again->id); $this->assertSame(1, Ticket::where('service_queue_id', $queue->id)->where('status', 'called')->count()); $engine->startServing($called1->fresh(), $user->public_id); $this->assertNull($engine->callNext($queue, $c1, $user->public_id)); // Other counter still gets the next waiting patient in FIFO order. $called2 = $engine->callNext($queue, $c2, $user->public_id); $this->assertSame($second->id, $called2->id); $this->assertSame($third->id, $engine->waitingTickets($queue)[0]->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'); } }