diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index 0c97f84..d932dce 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -125,7 +125,12 @@ class QueueController extends Controller $waitingCountQuery = Appointment::owned($owner) ->where('organization_id', $organization->id) - ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]); + ->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']); + }); if ($lockToPractitioner) { $waitingCountQuery->whereIn('practitioner_id', $practitionerScope ?: [0]); if ($branchId > 0) { @@ -142,6 +147,14 @@ class QueueController extends Controller $waitingCountQuery->whereRaw('1 = 0'); } + // Hero Waiting must match the Waiting column (same list the doctor acts on). + $waitingHero = $queue->reject( + fn ($a) => in_array($a->queue_ticket_status ?? '', ['called', 'serving'], true) + )->count(); + if ($queue->count() >= $boardLimit) { + $waitingHero = max($waitingHero, $waitingCountQuery->count()); + } + $inCareCountQuery = Appointment::owned($owner) ->where('organization_id', $organization->id) ->where('status', Appointment::STATUS_IN_CONSULTATION); @@ -172,7 +185,7 @@ class QueueController extends Controller ->get(); $heroStats = [ - 'waiting' => $waitingCountQuery->count(), + 'waiting' => $waitingHero, 'in_consultation' => $inCareCountQuery->count(), 'today' => $todayQuery->count(), ]; @@ -258,6 +271,25 @@ class QueueController extends Controller ? 'consultation' : $context; + $waitingVisible = Appointment::owned($owner) + ->where('organization_id', $organization->id) + ->where('branch_id', $branchId) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId)) + ->where(function ($q) { + $q->whereNull('queue_ticket_status') + ->orWhere('queue_ticket_status', '') + ->orWhereNotIn('queue_ticket_status', ['called', 'serving']); + }) + ->count(); + + if ($waitingVisible > 0) { + return back()->with( + 'info', + "{$waitingVisible} waiting but none are callable at your {$label} desk right now (payment hold or specialty mismatch)." + ); + } + return back()->with('info', "No patients waiting at your {$label} service point."); } diff --git a/app/Services/Care/CareQueueBridge.php b/app/Services/Care/CareQueueBridge.php index 5004471..efb2836 100644 --- a/app/Services/Care/CareQueueBridge.php +++ b/app/Services/Care/CareQueueBridge.php @@ -455,16 +455,37 @@ class CareQueueBridge ): array { $empty = ['ticket' => null, 'entity' => null, 'resources' => $resources]; - $appointment = $this->nextCallableAppointment($organization, $context, $branchId, $practitionerId); - if (! $appointment) { + $candidates = $this->callableAppointments($organization, $context, $branchId, $practitionerId); + if ($candidates === []) { $this->syncMissingTickets($organization, $context, $branchId, 25); - $appointment = $this->nextCallableAppointment($organization, $context, $branchId, $practitionerId); + $candidates = $this->callableAppointments($organization, $context, $branchId, $practitionerId); } - if (! $appointment) { - return $empty; + foreach ($candidates as $appointment) { + $called = $this->callSpecificAppointment($organization, $context, $branchId, $appointment, $resources, $point); + if ($called['ticket']) { + return $called; + } } + return $empty; + } + + /** + * @param array{queue_uuid?: ?string, counter_uuid?: ?string, points?: list>} $resources + * @param array $point + * @return array{ticket: ?array, entity: ?Model, resources: array} + */ + protected function callSpecificAppointment( + Organization $organization, + string $context, + int $branchId, + Appointment $appointment, + array $resources, + array $point, + ): array { + $empty = ['ticket' => null, 'entity' => null, 'resources' => $resources]; + if (blank($appointment->queue_ticket_uuid)) { $appointment = $this->issueForAppointment( $organization, @@ -505,6 +526,43 @@ class CareQueueBridge (string) $point['counter_uuid'], ); + // Stale uuid / wrong queue / non-waiting row — clear and re-issue onto this desk. + if (! $ticket) { + $appointment->forceFill([ + 'queue_ticket_uuid' => null, + 'queue_ticket_number' => null, + 'queue_ticket_status' => null, + 'queue_service_point_uuid' => null, + ])->save(); + + $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) { + return $empty; + } + $this->applyTicket($appointment, $ticket, $point); + $appointment = $appointment->fresh() ?? $appointment; + + $ticket = $this->attemptCallWaitingTicket( + $organization, + (string) $appointment->queue_ticket_uuid, + (string) $point['counter_uuid'], + ); + } + if (! $ticket) { return $empty; } @@ -518,12 +576,15 @@ class CareQueueBridge ]; } - protected function nextCallableAppointment( + /** + * @return list + */ + protected function callableAppointments( Organization $organization, string $context, int $branchId, ?int $practitionerId, - ): ?Appointment { + ): array { $owner = (string) $organization->owner_ref; $query = Appointment::owned($owner) @@ -546,6 +607,7 @@ class CareQueueBridge $query->where('practitioner_id', $practitionerId); } + $callable = []; foreach ($query->get() as $appointment) { if ($this->contextForAppointment($organization, $appointment) !== $context) { continue; @@ -557,10 +619,10 @@ class CareQueueBridge continue; } - return $appointment; + $callable[] = $appointment; } - return null; + return $callable; } /** diff --git a/app/Services/Care/CareQueueProvisioner.php b/app/Services/Care/CareQueueProvisioner.php index ab5bdb3..d37db18 100644 --- a/app/Services/Care/CareQueueProvisioner.php +++ b/app/Services/Care/CareQueueProvisioner.php @@ -475,6 +475,10 @@ class CareQueueProvisioner /** * Resolve a service point for a practitioner on a queue context. * + * When a doctor is linked after queues were first provisioned, ensure() may + * early-return with a stale points list — refresh provisioning on miss so + * Call next can still reach board waiters assigned to that desk. + * * @return array|null */ public function pointForPractitioner( @@ -482,17 +486,93 @@ class CareQueueProvisioner int $branchId, int $practitionerId, string $context = CareQueueContexts::CONSULTATION, + ): ?array { + $point = $this->findPractitionerPoint($organization, $context, $branchId, $practitionerId); + if ($point && ! empty($point['counter_uuid'])) { + return $point; + } + + if (! $this->shouldProvision($organization)) { + return $this->practitionerPointFromDb($organization, $context, $branchId, $practitionerId); + } + + if (CareQueueContexts::isSpecialty($context)) { + $this->provisionSpecialtyContextBranch($organization->fresh() ?? $organization, $context, $branchId); + } elseif (CareQueueContexts::isDepartment($context)) { + $this->provisionContextBranch($organization->fresh() ?? $organization, $context, $branchId); + } else { + return $this->practitionerPointFromDb($organization, $context, $branchId, $practitionerId); + } + + $organization = $organization->fresh() ?? $organization; + $point = $this->findPractitionerPoint($organization, $context, $branchId, $practitionerId); + if ($point && ! empty($point['counter_uuid'])) { + return $point; + } + + return $this->practitionerPointFromDb($organization, $context, $branchId, $practitionerId); + } + + /** + * @return array|null + */ + protected function findPractitionerPoint( + Organization $organization, + string $context, + int $branchId, + int $practitionerId, ): ?array { $resources = $this->resourcesFor($organization, $context, $branchId); if (! $resources) { return null; } - return collect($resources['points'])->first( + return collect($resources['points'] ?? [])->first( fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId ); } + /** + * @return array|null + */ + protected function practitionerPointFromDb( + Organization $organization, + string $context, + int $branchId, + int $practitionerId, + ): ?array { + $row = CareServicePoint::query() + ->where('organization_id', $organization->id) + ->where('kind', 'practitioner') + ->where('ref_id', $practitionerId) + ->where('is_active', true) + ->whereHas( + 'serviceQueue', + fn ($q) => $q + ->where('organization_id', $organization->id) + ->where('context', $context) + ->where('branch_id', $branchId) + ->where('is_active', true), + ) + ->first(); + + if (! $row) { + return null; + } + + return [ + 'kind' => 'practitioner', + 'ref_id' => $practitionerId, + 'name' => $row->name, + 'destination' => $row->destination, + 'staff_ref' => $row->staff_ref, + 'staff_display_name' => $row->staff_display_name, + 'external_key' => $row->external_key, + 'counter_uuid' => $row->uuid, + 'synced' => true, + ]; + } + /** * @return array|null */ diff --git a/tests/Feature/CareQueueBridgeTest.php b/tests/Feature/CareQueueBridgeTest.php index 3a88368..3fb3e13 100644 --- a/tests/Feature/CareQueueBridgeTest.php +++ b/tests/Feature/CareQueueBridgeTest.php @@ -506,6 +506,93 @@ class CareQueueBridgeTest extends TestCase Http::assertNothingSent(); } + public function test_assigned_doctor_call_next_succeeds_with_waiting_appointments(): void + { + Http::fake(); + + // Provision consultation desks for an existing GP first so ensure() early-returns + // with a points list that does not yet include the demo doctor we link next. + $other = \App\Models\Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Dr. Other Desk', + 'room' => 'Room 1', + 'is_active' => true, + ]); + + app(\App\Services\Care\CareQueueProvisioner::class)->ensure( + $this->organization->fresh(), + CareQueueContexts::CONSULTATION, + $this->branch->id, + ); + + $doctorUser = User::create([ + 'public_id' => 'bridge-assigned-doc', + 'name' => 'Assigned Doctor', + 'email' => 'bridge-assigned@example.com', + ]); + $doctorMember = Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $doctorUser->public_id, + 'role' => 'doctor', + 'branch_id' => $this->branch->id, + ]); + $doctor = \App\Models\Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'member_id' => $doctorMember->id, + 'user_ref' => $doctorUser->public_id, + 'name' => 'Dr. Assigned', + 'room' => 'Room 9', + 'is_active' => true, + ]); + + 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, + ]); + + // Board KPI / Assigned to you still lists the waiter. + $this->actingAs($doctorUser) + ->get(route('care.queue.index', ['branch_id' => $this->branch->id])) + ->assertOk() + ->assertSee('Assigned to you') + ->assertSee('Ada Lovelace'); + + $response = $this->actingAs($doctorUser) + ->from(route('care.queue.index', ['branch_id' => $this->branch->id])) + ->post(route('care.queue.call-next'), [ + 'branch_id' => $this->branch->id, + ]) + ->assertSessionMissing('info') + ->assertSessionHas('success'); + + $appointment = Appointment::query()->where('practitioner_id', $doctor->id)->first(); + $this->assertNotNull($appointment?->queue_ticket_uuid); + $this->assertSame('called', $appointment->queue_ticket_status); + $response->assertRedirect(route('care.appointments.show', $appointment)); + + $this->assertDatabaseHas('care_service_points', [ + 'organization_id' => $this->organization->id, + 'kind' => 'practitioner', + 'ref_id' => $doctor->id, + 'is_active' => true, + ]); + $this->assertNotNull($other->fresh()); + Http::assertNothingSent(); + } + public function test_call_next_reclaims_ticket_from_wrong_desk_and_announces(): void { Http::fake();