Fix Call next by advancing Care waiters and reclaiming desk tickets.
Deploy Ladill Care / deploy (push) Successful in 51s

Call next now picks the next board appointment, issues or reclaims its ticket onto the calling desk, and still fires display/voice announcements — so patients are not left stuck when tickets sit on the wrong point after sync-on-GET was removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 20:18:52 +00:00
co-authored by Cursor
parent eaba400c7c
commit c123097f3c
3 changed files with 397 additions and 17 deletions
+203 -1
View File
@@ -4,6 +4,7 @@ namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\CareQueueTicket;
use App\Models\InvestigationRequest;
use App\Models\Member;
use App\Models\Organization;
@@ -376,6 +377,22 @@ class CareQueueBridge
return ['ticket' => null, 'entity' => null, 'resources' => $resources];
}
// GP / specialty: advance the next Care waiter (board order), even when their
// ticket sits on another desk or was never issued (sync-on-GET was removed).
if ($this->isAppointmentQueueContext($context)) {
$fromCare = $this->callNextAppointment(
$organization,
$context,
$branchId,
$practitionerId,
$resources,
$point,
);
if ($fromCare['ticket']) {
return $fromCare;
}
}
$callResources = [
'queue_uuid' => $resources['queue_uuid'],
'counter_uuid' => $point['counter_uuid'],
@@ -415,6 +432,157 @@ class CareQueueBridge
return ['ticket' => $ticket, 'entity' => $entity?->fresh(), 'resources' => $resources];
}
protected function isAppointmentQueueContext(string $context): bool
{
return $context === CareQueueContexts::CONSULTATION
|| CareQueueContexts::isSpecialty($context);
}
/**
* Call the next waiting appointment for this context matching board order.
*
* @param array{queue_uuid?: ?string, counter_uuid?: ?string, points?: list<array<string, mixed>>} $resources
* @param array<string, mixed> $point
* @return array{ticket: ?array<string, mixed>, entity: ?Model, resources: array<string, mixed>}
*/
protected function callNextAppointment(
Organization $organization,
string $context,
int $branchId,
?int $practitionerId,
array $resources,
array $point,
): array {
$empty = ['ticket' => null, 'entity' => null, 'resources' => $resources];
$appointment = $this->nextCallableAppointment($organization, $context, $branchId, $practitionerId);
if (! $appointment) {
$this->syncMissingTickets($organization, $context, $branchId, 25);
$appointment = $this->nextCallableAppointment($organization, $context, $branchId, $practitionerId);
}
if (! $appointment) {
return $empty;
}
if (blank($appointment->queue_ticket_uuid)) {
$appointment = $this->issueForAppointment(
$organization,
$appointment->fresh(['patient', 'organization', 'practitioner', 'visit']) ?? $appointment,
) ?? $appointment;
}
if (blank($appointment->queue_ticket_uuid)) {
// Last resort: issue directly onto the calling desk.
$ticket = $this->issue(
$organization,
$context,
$branchId,
$appointment->patient,
[
'care_entity' => 'appointment',
'care_entity_uuid' => $appointment->uuid,
'care_entity_id' => $appointment->id,
'practitioner_id' => $appointment->practitioner_id,
],
$appointment->type === Appointment::TYPE_WALK_IN ? 'walk_in' : 'appointment',
'appointment',
$point,
);
if ($ticket) {
$this->applyTicket($appointment, $ticket, $point);
$appointment = $appointment->fresh() ?? $appointment;
}
}
if (blank($appointment->queue_ticket_uuid)) {
return $empty;
}
$ticket = $this->attemptCallWaitingTicket(
$organization,
(string) $appointment->queue_ticket_uuid,
(string) $point['counter_uuid'],
);
if (! $ticket) {
return $empty;
}
$this->applyTicket($appointment, $ticket, $point);
return [
'ticket' => $ticket,
'entity' => $appointment->fresh(),
'resources' => $resources,
];
}
protected function nextCallableAppointment(
Organization $organization,
string $context,
int $branchId,
?int $practitionerId,
): ?Appointment {
$owner = (string) $organization->owner_ref;
$query = Appointment::owned($owner)
->where('organization_id', $organization->id)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->where(function ($q) {
$q->whereNull('queue_ticket_status')
->orWhere('queue_ticket_status', '')
->orWhereNotIn('queue_ticket_status', ['called', 'serving']);
})
->with(['patient', 'organization', 'practitioner', 'visit'])
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at')
->orderBy('id')
->limit(50);
if ($practitionerId) {
$query->where('practitioner_id', $practitionerId);
}
foreach ($query->get() as $appointment) {
if ($this->contextForAppointment($organization, $appointment) !== $context) {
continue;
}
// Already ticketed waiters stay callable; gate only blocks new issuance.
if (blank($appointment->queue_ticket_uuid)
&& ! $this->workflowGate->canRelease($organization, $appointment->visit, $context)) {
continue;
}
return $appointment;
}
return null;
}
/**
* @return array<string, mixed>|null
*/
protected function attemptCallWaitingTicket(
Organization $organization,
string $ticketUuid,
string $counterUuid,
): ?array {
try {
return $this->engine->callWaitingTicket($organization, $ticketUuid, $counterUuid);
} catch (\Throwable $e) {
Log::warning('care.queue_call_waiting_ticket_failed', [
'ticket_uuid' => $ticketUuid,
'message' => $e->getMessage(),
]);
return null;
}
}
/**
* @param array{queue_uuid?: ?string, counter_uuid?: ?string, points?: list<array<string, mixed>>} $resources
* @param array<string, mixed> $preferredPoint
@@ -736,7 +904,7 @@ class CareQueueBridge
$owner = (string) $organization->owner_ref;
return match (true) {
$entity = match (true) {
$context === CareQueueContexts::PHARMACY => Prescription::owned($owner)
->where('organization_id', $organization->id)
->where('queue_ticket_uuid', $ticketUuid)
@@ -754,6 +922,40 @@ class CareQueueBridge
->where('queue_ticket_uuid', $ticketUuid)
->first(),
};
if ($entity) {
return $entity;
}
// Recover when the Care row lost its uuid mirror but the ticket still points at it.
$ticket = CareQueueTicket::query()
->where('organization_id', $organization->id)
->where('uuid', $ticketUuid)
->first();
if (! $ticket || ! $ticket->care_entity_id) {
return null;
}
return match ($ticket->care_entity) {
'prescription' => Prescription::owned($owner)
->where('organization_id', $organization->id)
->whereKey($ticket->care_entity_id)
->first(),
'investigation_request' => InvestigationRequest::owned($owner)
->where('organization_id', $organization->id)
->whereKey($ticket->care_entity_id)
->first(),
'bill' => Bill::owned($owner)
->where('organization_id', $organization->id)
->whereKey($ticket->care_entity_id)
->first(),
'appointment' => Appointment::owned($owner)
->where('organization_id', $organization->id)
->whereKey($ticket->care_entity_id)
->first(),
default => null,
};
}
protected function syncMissingAppointments(Organization $organization, string $context, int $branchId, int $limit): int
+75 -16
View File
@@ -127,7 +127,11 @@ class CareQueueEngine
->where('status', CareQueueTicket::STATUS_WAITING)
->when(
$queue->routing_mode === 'assigned_only',
fn ($q) => $q->where('service_point_id', $point->id),
// Claim desk tickets and unassigned waiters (null point) — never other desks.
fn ($q) => $q->where(function ($inner) use ($point) {
$inner->where('service_point_id', $point->id)
->orWhereNull('service_point_id');
}),
fn ($q) => $q->where(function ($inner) use ($point) {
$inner->whereNull('service_point_id')
->orWhere('service_point_id', $point->id);
@@ -142,28 +146,83 @@ class CareQueueEngine
return null;
}
$waiting->forceFill([
'service_point_id' => $point->id,
'status' => CareQueueTicket::STATUS_CALLED,
'called_at' => now(),
])->save();
return $this->markTicketCalled($waiting, $point, $queue);
});
}
$waiting->setRelation('servicePoint', $point);
$waiting->setRelation('serviceQueue', $queue);
/**
* Call a specific waiting ticket to a service point (Care board ticket handoff).
* Reassigns the ticket when it was waiting on another desk or unassigned.
*
* @return array<string, mixed>|null
*/
public function callWaitingTicket(Organization $organization, string $ticketUuid, string $counterUuid): ?array
{
return DB::transaction(function () use ($organization, $ticketUuid, $counterUuid) {
/** @var CareQueueTicket|null $ticket */
$ticket = CareQueueTicket::query()
->where('organization_id', $organization->id)
->where('uuid', $ticketUuid)
->lockForUpdate()
->first();
try {
app(CareVoiceAnnouncementService::class)->announceCalled($waiting, $point);
} catch (Throwable $e) {
Log::warning('Care voice announcement failed on call-next', [
'ticket_id' => $waiting->id,
'error' => $e->getMessage(),
]);
if (! $ticket || $ticket->status !== CareQueueTicket::STATUS_WAITING) {
return null;
}
return $this->toApiArray($waiting);
/** @var CareServiceQueue|null $queue */
$queue = CareServiceQueue::query()
->where('organization_id', $organization->id)
->whereKey($ticket->service_queue_id)
->lockForUpdate()
->first();
if (! $queue || ! $queue->is_active) {
return null;
}
/** @var CareServicePoint|null $point */
$point = CareServicePoint::query()
->where('organization_id', $organization->id)
->where('service_queue_id', $queue->id)
->where('uuid', $counterUuid)
->where('is_active', true)
->first();
if (! $point) {
return null;
}
return $this->markTicketCalled($ticket, $point, $queue);
});
}
/**
* @return array<string, mixed>
*/
protected function markTicketCalled(CareQueueTicket $ticket, CareServicePoint $point, CareServiceQueue $queue): array
{
$ticket->forceFill([
'service_point_id' => $point->id,
'status' => CareQueueTicket::STATUS_CALLED,
'called_at' => now(),
])->save();
$ticket->setRelation('servicePoint', $point);
$ticket->setRelation('serviceQueue', $queue);
try {
app(CareVoiceAnnouncementService::class)->announceCalled($ticket, $point);
} catch (Throwable $e) {
Log::warning('Care voice announcement failed on call-next', [
'ticket_id' => $ticket->id,
'error' => $e->getMessage(),
]);
}
return $this->toApiArray($ticket);
}
/**
* @param array{action: string} $payload
* @return array<string, mixed>
+119
View File
@@ -505,4 +505,123 @@ class CareQueueBridgeTest extends TestCase
->assertSee('Call again');
Http::assertNothingSent();
}
public function test_call_next_reclaims_ticket_from_wrong_desk_and_announces(): void
{
Http::fake();
$doctor = \App\Models\Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'name' => 'Dr. Reclaim',
'room' => 'Room 7',
'is_active' => true,
]);
$other = \App\Models\Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'name' => 'Dr. Other',
'room' => 'Room 8',
'is_active' => true,
]);
app(\App\Services\Care\CareQueueProvisioner::class)->ensure(
$this->organization->fresh(),
CareQueueContexts::CONSULTATION,
$this->branch->id,
);
$queue = \App\Models\CareServiceQueue::query()
->where('organization_id', $this->organization->id)
->where('context', CareQueueContexts::CONSULTATION)
->where('branch_id', $this->branch->id)
->firstOrFail();
$doctorPoint = \App\Models\CareServicePoint::query()
->where('service_queue_id', $queue->id)
->where('kind', 'practitioner')
->where('ref_id', $doctor->id)
->firstOrFail();
$otherPoint = \App\Models\CareServicePoint::query()
->where('service_queue_id', $queue->id)
->where('kind', 'practitioner')
->where('ref_id', $other->id)
->firstOrFail();
$ticketUuid = (string) \Illuminate\Support\Str::uuid();
CareQueueTicket::create([
'uuid' => $ticketUuid,
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'service_queue_id' => $queue->id,
'service_point_id' => $otherPoint->id,
'ticket_number' => 'C099',
'status' => CareQueueTicket::STATUS_WAITING,
'priority' => 'walk_in',
'customer_name' => 'Ada Lovelace',
'care_entity' => 'appointment',
'care_entity_id' => null,
]);
$appointment = Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'practitioner_id' => $doctor->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'waiting_at' => now(),
'queue_position' => 1,
'queue_ticket_uuid' => $ticketUuid,
'queue_ticket_number' => 'C099',
'queue_ticket_status' => 'waiting',
]);
CareQueueTicket::where('uuid', $ticketUuid)->update([
'care_entity_id' => $appointment->id,
'care_entity_uuid' => $appointment->uuid,
]);
$this->actingAs($this->owner)
->from(route('care.queue.index'))
->post(route('care.queue.call-next'), [
'branch_id' => $this->branch->id,
'practitioner_id' => $doctor->id,
])
->assertSessionHas('success')
->assertRedirect(route('care.appointments.show', $appointment));
$appointment = $appointment->fresh();
$this->assertSame('called', $appointment->queue_ticket_status);
$this->assertSame($doctorPoint->uuid, $appointment->queue_service_point_uuid);
$this->assertDatabaseHas('care_queue_tickets', [
'uuid' => $ticketUuid,
'status' => CareQueueTicket::STATUS_CALLED,
'service_point_id' => $doctorPoint->id,
]);
$this->assertDatabaseHas('care_voice_announcements', [
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'status' => 'pending',
]);
$this->actingAs($this->owner)
->get(route('care.queue.index', [
'branch_id' => $this->branch->id,
'practitioner_id' => $doctor->id,
]))
->assertOk()
->assertSee('Called')
->assertSee('C099');
Http::assertNothingSent();
}
}