Fix pharmacy Call next to FIFO shared pool with one patient per counter.
Deploy Ladill Queue / deploy (push) Successful in 46s

Pharmacy/lab/billing now use a shared waiting pool claimed in order on Call next. A counter cannot stack another called ticket while one is already called or being served, so pharmacists no longer pull randomly or multiple patients at once.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 19:06:30 +00:00
co-authored by Cursor
parent 5356118fd8
commit d91f632544
3 changed files with 111 additions and 11 deletions
+32 -7
View File
@@ -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;
}
/**
+4 -4
View File
@@ -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' => [
+75
View File
@@ -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']);