Deploy Ladill Queue / deploy (push) Successful in 3m12s
Queue is general-purpose QMS only; Care runs its own native engine. Co-authored-by: Cursor <cursoragent@cursor.com>
403 lines
15 KiB
PHP
403 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Http\Middleware\EnsurePlatformSession;
|
|
use App\Models\Branch;
|
|
use App\Models\Counter;
|
|
use App\Models\DisplayScreen;
|
|
use App\Models\Organization;
|
|
use App\Models\ServiceQueue;
|
|
use App\Models\Ticket;
|
|
use App\Models\User;
|
|
use App\Services\Qms\DisplayService;
|
|
use App\Services\Qms\OrganizationResolver;
|
|
use App\Services\Qms\QueueEngine;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class ServicePointRoutingTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->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' => 'banking',
|
|
'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_create_page_shows_service_point_without_alpine_leak(): void
|
|
{
|
|
[$user, , , $queue] = $this->setUpAssignedConsultation();
|
|
|
|
$this->actingAs($user)
|
|
->get(route('qms.tickets.create', ['queue' => $queue->id]))
|
|
->assertOk()
|
|
->assertSee('Service point')
|
|
->assertSee('data-create-url', false)
|
|
->assertDontSee('needsPoint')
|
|
->assertDontSee('onQueueChange')
|
|
->assertDontSee('@json');
|
|
}
|
|
|
|
public function test_ticket_priority_labels_follow_industry_package(): void
|
|
{
|
|
$user = User::create([
|
|
'public_id' => 'prio-rest-owner',
|
|
'name' => 'Restaurant Owner',
|
|
'email' => 'prio-rest@example.com',
|
|
'password' => bcrypt('password'),
|
|
]);
|
|
app(OrganizationResolver::class)->completeOnboarding($user, [
|
|
'organization_name' => 'Bistro',
|
|
'industry' => 'restaurant',
|
|
'appointment_mode' => 'hybrid',
|
|
'branch_name' => 'Main',
|
|
'timezone' => 'UTC',
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->get(route('qms.tickets.create'))
|
|
->assertOk()
|
|
->assertSee('Rush')
|
|
->assertSee('Reservation')
|
|
->assertSee('VIP table')
|
|
->assertDontSee('>Emergency<', false)
|
|
->assertDontSee('>Elderly<', false);
|
|
}
|
|
|
|
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_frontdesk_api_can_issue_assigned_ticket(): void
|
|
{
|
|
config(['qms.service_api_keys.frontdesk' => 'test-frontdesk-key']);
|
|
$owner = 'frontdesk-sp-owner';
|
|
$headers = [
|
|
'Authorization' => 'Bearer test-frontdesk-key',
|
|
'Accept' => 'application/json',
|
|
];
|
|
|
|
$this->postJson('/api/v1/integrations/provision', [
|
|
'owner' => $owner,
|
|
'organization_name' => 'Visitor Desk',
|
|
'branch_name' => 'Main',
|
|
], $headers)->assertSuccessful();
|
|
|
|
$queueUuid = $this->postJson('/api/v1/queues', [
|
|
'owner' => $owner,
|
|
'name' => 'Consultation',
|
|
'prefix' => 'C',
|
|
'branch_name' => 'Main',
|
|
'external_key' => 'frontdesk: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' => 'frontdesk: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');
|
|
}
|
|
}
|