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