Add service-point routing so call-next cannot steal assigned tickets.
Deploy Ladill Queue / deploy (push) Successful in 2m17s
Deploy Ladill Queue / deploy (push) Successful in 2m17s
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\Counter;
|
||||
use App\Models\DisplayScreen;
|
||||
use App\Models\Organization;
|
||||
use App\Models\ServiceQueue;
|
||||
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;
|
||||
|
||||
/**
|
||||
* @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_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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user