diff --git a/app/Services/Care/CareQueueBridge.php b/app/Services/Care/CareQueueBridge.php index 9afef25..5004471 100644 --- a/app/Services/Care/CareQueueBridge.php +++ b/app/Services/Care/CareQueueBridge.php @@ -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>} $resources + * @param array $point + * @return array{ticket: ?array, entity: ?Model, resources: array} + */ + 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|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>} $resources * @param array $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 diff --git a/app/Services/Care/CareQueueEngine.php b/app/Services/Care/CareQueueEngine.php index 857f180..bb00cd9 100644 --- a/app/Services/Care/CareQueueEngine.php +++ b/app/Services/Care/CareQueueEngine.php @@ -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|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 + */ + 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 diff --git a/tests/Feature/CareQueueBridgeTest.php b/tests/Feature/CareQueueBridgeTest.php index 2c3c0be..3a88368 100644 --- a/tests/Feature/CareQueueBridgeTest.php +++ b/tests/Feature/CareQueueBridgeTest.php @@ -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(); + } }