diff --git a/app/Services/Qms/QueueEngine.php b/app/Services/Qms/QueueEngine.php index 8dd8528..1d9e749 100644 --- a/app/Services/Qms/QueueEngine.php +++ b/app/Services/Qms/QueueEngine.php @@ -101,6 +101,27 @@ class QueueEngine public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket { + // One patient at a time per service point: finish serving before calling another. + $activeServing = Ticket::query() + ->where('service_queue_id', $queue->id) + ->where('counter_id', $counter->id) + ->where('status', 'serving') + ->exists(); + if ($activeServing) { + return null; + } + + // Already called someone at this counter — re-announce instead of stacking another patient. + $alreadyCalled = Ticket::query() + ->where('service_queue_id', $queue->id) + ->where('counter_id', $counter->id) + ->where('status', 'called') + ->orderByDesc('called_at') + ->first(); + if ($alreadyCalled) { + return $this->recall($alreadyCalled, $actorRef); + } + $ticket = $this->nextWaitingTicket($queue, $counter); if (! $ticket) { return null; @@ -111,14 +132,18 @@ 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) { + $ticket->loadMissing('serviceQueue'); + $queue = $ticket->serviceQueue; + if ($queue?->usesAssignedOnlyRouting() + && $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, + 'assigned_counter_id' => $counter->id, 'called_at' => now(), ]); @@ -402,7 +427,8 @@ class QueueEngine ->where('service_queue_id', $queue->id) ->where('status', 'waiting') ->orderByRaw($this->priorityOrderSql()) - ->orderBy('issued_at'); + ->orderBy('issued_at') + ->orderBy('id'); if (! $counter) { return $query; @@ -412,10 +438,9 @@ class QueueEngine 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); - }); + // Shared pool (pharmacy, bank tellers, etc.): FIFO across the whole queue. + // Prior counter assignment does not block another desk from claiming the next waiting ticket. + return $query; } /** diff --git a/config/industry_packages.php b/config/industry_packages.php index 52606e9..8b2f082 100644 --- a/config/industry_packages.php +++ b/config/industry_packages.php @@ -101,7 +101,7 @@ return [ 'name' => 'Laboratory', 'prefix' => 'L', 'strategy' => 'fifo', - 'routing_mode' => 'assigned_only', + 'routing_mode' => 'shared_pool', 'routing_strategy' => 'least_busy', 'optional' => true, 'service_points' => [ @@ -114,7 +114,7 @@ return [ 'name' => 'Imaging', 'prefix' => 'I', 'strategy' => 'fifo', - 'routing_mode' => 'assigned_only', + 'routing_mode' => 'shared_pool', 'routing_strategy' => 'skill_based', 'optional' => true, 'service_points' => [ @@ -127,7 +127,7 @@ return [ 'name' => 'Pharmacy', 'prefix' => 'P', 'strategy' => 'fifo', - 'routing_mode' => 'assigned_only', + 'routing_mode' => 'shared_pool', 'routing_strategy' => 'least_busy', 'optional' => false, 'service_points' => [ @@ -140,7 +140,7 @@ return [ 'name' => 'Billing', 'prefix' => 'B', 'strategy' => 'fifo', - 'routing_mode' => 'assigned_only', + 'routing_mode' => 'shared_pool', 'routing_strategy' => 'round_robin', 'optional' => false, 'service_points' => [ diff --git a/tests/Feature/ServicePointRoutingTest.php b/tests/Feature/ServicePointRoutingTest.php index 249609e..f6910d9 100644 --- a/tests/Feature/ServicePointRoutingTest.php +++ b/tests/Feature/ServicePointRoutingTest.php @@ -7,6 +7,7 @@ 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; @@ -197,6 +198,80 @@ class ServicePointRoutingTest extends TestCase $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']);