Fix Call next by advancing Care waiters and reclaiming desk tickets.
Deploy Ladill Care / deploy (push) Successful in 51s
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user