Fix pharmacy Call next to FIFO shared pool with one patient per counter.
Deploy Ladill Queue / deploy (push) Successful in 46s
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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user