diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 4b49132..b4604bc 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -304,10 +304,50 @@ class SpecialtyModuleController extends Controller $result = $queueBridge->callNext($organization, $module, $branchId, $member, $practitionerId); $ticket = $result['ticket'] ?? null; if (! $ticket) { + $owner = $this->ownerRef($request); + $departmentIds = Department::owned($owner) + ->where('type', $definition['department_type'] ?? 'general') + ->where('is_active', true) + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->pluck('id'); + + $waitingAtBranch = Appointment::owned($owner) + ->where('organization_id', $organization->id) + ->where('branch_id', $branchId) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->when($departmentIds->isNotEmpty(), fn ($q) => $q->whereIn('department_id', $departmentIds)) + ->when($departmentIds->isEmpty(), fn ($q) => $q->whereRaw('1 = 0')) + ->count(); + + $waitingOrg = Appointment::owned($owner) + ->where('organization_id', $organization->id) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->when($departmentIds->isNotEmpty(), fn ($q) => $q->whereIn('department_id', $departmentIds)) + ->when($departmentIds->isEmpty(), fn ($q) => $q->whereRaw('1 = 0')) + ->count(); + + $branchName = \App\Models\Branch::owned($owner)->whereKey($branchId)->value('name') ?: 'this branch'; + + if ($waitingAtBranch > 0) { + return back()->with( + 'info', + "{$waitingAtBranch} patient(s) are waiting in {$definition['label']} at {$branchName}, but none have a callable ticket at a service desk yet. Open Visits and use Start consultation, or check in again so a ticket is issued." + ); + } + + if ($waitingOrg > $waitingAtBranch) { + return back()->with( + 'info', + "No patients waiting in {$definition['label']} at {$branchName}. {$waitingOrg} are waiting at other branches — open Visits or switch branch." + ); + } + return back()->with('info', 'No patients waiting in this specialty queue.'); } - return back()->with('success', 'Called '.$ticket['ticket_number'].'.'); + $name = $ticket['customer_name'] ?? 'patient'; + + return back()->with('success', 'Called '.$ticket['ticket_number'].' — '.$name.'.'); } protected function renderShell( @@ -341,22 +381,6 @@ class SpecialtyModuleController extends Controller $departmentIds = $departments->pluck('id')->all(); - $waiting = Appointment::owned($owner) - ->where('organization_id', $organization->id) - ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) - ->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds)) - ->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0')) - ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) - ->when( - $practitionerScope !== null, - fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]), - ) - ->with(['patient', 'practitioner', 'department', 'visit']) - ->orderBy('queue_position') - ->orderBy('checked_in_at') - ->limit(25) - ->get(); - $branchId = (int) ($branchScope ?: $departments->first()?->branch_id); if ($practitionerScope !== null && $practitionerScope !== []) { $pracBranch = (int) \App\Models\Practitioner::owned($owner) @@ -367,11 +391,32 @@ class SpecialtyModuleController extends Controller } } + // Match Call next: when the member has no branch scope (e.g. hospital admin), + // KPIs / waiting lists use the same branch as queue ops so WAITING isn't org-wide + // while Call next is local. + $kpiBranch = $branchScope ?? ($branchId > 0 ? $branchId : null); + + $waiting = Appointment::owned($owner) + ->where('organization_id', $organization->id) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds)) + ->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0')) + ->when($kpiBranch, fn ($q) => $q->where('branch_id', $kpiBranch)) + ->when( + $practitionerScope !== null, + fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]), + ) + ->with(['patient', 'practitioner', 'department', 'visit']) + ->orderBy('queue_position') + ->orderBy('checked_in_at') + ->limit(25) + ->get(); + $shellDefinition = $shell->definition($module); - $kpis = $shell->kpis($organization, $module, $owner, $branchScope, $practitionerScope); - $openVisits = $shell->openVisits($organization, $module, $owner, $branchScope, $practitionerScope); + $kpis = $shell->kpis($organization, $module, $owner, $kpiBranch, $practitionerScope); + $openVisits = $shell->openVisits($organization, $module, $owner, $kpiBranch, $practitionerScope); $visitHistory = $section === 'history' - ? $shell->visitHistory($organization, $module, $owner, $branchScope, $practitionerScope) + ? $shell->visitHistory($organization, $module, $owner, $kpiBranch, $practitionerScope) : collect(); $services = $shell->provisionedServices($organization, $module); @@ -472,6 +517,7 @@ class SpecialtyModuleController extends Controller 'visitDocuments' => $visitDocuments, 'queueStubs' => $modules->queueStubsFor($organization, $module), 'branchId' => $branchId, + 'practitionerScope' => $practitionerScope, 'queueIntegration' => $branchId ? $queueBridge->listMeta($organization, $module, $branchId) : ['enabled' => false], diff --git a/app/Services/Care/CareQueueBridge.php b/app/Services/Care/CareQueueBridge.php index 255665e..53a3921 100644 --- a/app/Services/Care/CareQueueBridge.php +++ b/app/Services/Care/CareQueueBridge.php @@ -346,18 +346,72 @@ class CareQueueBridge $ticket = $this->attemptCallNext($organization, $callResources); } + // Admins / reception without a desk: try every service point on this queue so + // Call next matches the specialty waiting list instead of one empty desk. + if (! $ticket && ! $practitionerId && ( + CareQueueContexts::isSpecialty($context) + || $context === CareQueueContexts::CONSULTATION + )) { + $ticket = $this->attemptCallNextAcrossPoints($organization, $resources, $point); + } + if (! $ticket) { return ['ticket' => null, 'entity' => null, 'resources' => $resources]; } $entity = $this->findEntityByTicket($organization, $context, (string) ($ticket['uuid'] ?? '')); if ($entity) { - $this->applyTicket($entity, $ticket, $point); + $matchedPoint = $point; + $ticketPointUuid = (string) data_get($ticket, 'assigned_counter.uuid', data_get($ticket, 'counter.uuid', '')); + if ($ticketPointUuid !== '') { + $matchedPoint = collect($resources['points'] ?? [])->first( + fn ($p) => ($p['counter_uuid'] ?? null) === $ticketPointUuid + ) ?? $point; + } + $this->applyTicket($entity, $ticket, $matchedPoint); } return ['ticket' => $ticket, 'entity' => $entity?->fresh(), 'resources' => $resources]; } + /** + * @param array{queue_uuid?: ?string, counter_uuid?: ?string, points?: list>} $resources + * @param array $preferredPoint + * @return array|null + */ + protected function attemptCallNextAcrossPoints( + Organization $organization, + array $resources, + array $preferredPoint, + ): ?array { + $queueUuid = (string) ($resources['queue_uuid'] ?? ''); + if ($queueUuid === '') { + return null; + } + + $preferredUuid = (string) ($preferredPoint['counter_uuid'] ?? ''); + $points = collect($resources['points'] ?? []) + ->filter(fn ($p) => ! empty($p['counter_uuid'])) + ->sortBy(fn ($p) => ($p['counter_uuid'] ?? '') === $preferredUuid ? 0 : 1) + ->values(); + + foreach ($points as $candidate) { + $uuid = (string) $candidate['counter_uuid']; + if ($uuid === $preferredUuid) { + continue; + } + $ticket = $this->attemptCallNext($organization, [ + 'queue_uuid' => $queueUuid, + 'counter_uuid' => $uuid, + ]); + if ($ticket) { + return $ticket; + } + } + + return null; + } + /** * @param array{queue_uuid?: ?string, counter_uuid?: ?string} $resources * @return array|null diff --git a/resources/views/care/partials/queue-ops.blade.php b/resources/views/care/partials/queue-ops.blade.php index 83cafc8..910010f 100644 --- a/resources/views/care/partials/queue-ops.blade.php +++ b/resources/views/care/partials/queue-ops.blade.php @@ -11,6 +11,8 @@

@if (($qi['routing_mode'] ?? null) === 'shared_pool') Call next takes the next waiting patient in line for this queue (shared pool). + @elseif (! empty($queueCallNextScansAllPoints)) + Call next picks the next waiting patient across service desks for this specialty at this branch. @else Call next operates on your assigned service point only — tickets routed to other rooms or desks stay there. @endif diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index 241a3f2..04bbafb 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -72,6 +72,7 @@ 'queueCallNextRoute' => 'care.specialty.call-next', 'queueCallNextParams' => ['module' => $moduleKey], 'branchId' => $branchId ?? null, + 'queueCallNextScansAllPoints' => ($practitionerScope ?? null) === null, ]) @if ($workspaceVisit) diff --git a/tests/Feature/CareServicePointRoutingTest.php b/tests/Feature/CareServicePointRoutingTest.php index d2d196f..bbee209 100644 --- a/tests/Feature/CareServicePointRoutingTest.php +++ b/tests/Feature/CareServicePointRoutingTest.php @@ -8,6 +8,7 @@ use App\Models\Branch; use App\Models\CareQueueTicket; use App\Models\CareServicePoint; use App\Models\CareServiceQueue; +use App\Models\Department; use App\Models\Member; use App\Models\Organization; use App\Models\Patient; @@ -336,4 +337,114 @@ class CareServicePointRoutingTest extends TestCase $this->assertCount(count($first), $second); Http::assertNothingSent(); } + + public function test_admin_specialty_call_next_finds_ticket_on_other_service_point(): void + { + Http::fake(); + + $department = Department::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Dentistry', + 'type' => 'dental', + 'is_active' => true, + ]); + + $dentistA = Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'department_id' => $department->id, + 'name' => 'Dr. Dental A', + 'specialty' => 'Dentistry', + 'room' => 'Bay 1', + 'is_active' => true, + ]); + + $dentistB = Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'department_id' => $department->id, + 'name' => 'Dr. Dental B', + 'specialty' => 'Dentistry', + 'room' => 'Bay 2', + 'is_active' => true, + ]); + + $settings = $this->organization->settings; + $settings['specialty_modules'] = ['dentistry' => true]; + $settings['specialty_module_provisioning'] = [ + 'dentistry' => [ + 'active' => true, + 'department_ids' => [$department->id], + 'queues' => [[ + 'module' => 'dentistry', + 'branch_id' => $this->branch->id, + 'branch_name' => 'Main', + 'name' => 'Dentistry', + 'prefix' => 'DEN', + 'active' => true, + 'synced' => false, + ]], + ], + ]; + $this->organization->update(['settings' => $settings]); + + app(CareQueueProvisioner::class)->ensure( + $this->organization->fresh(), + 'dentistry', + $this->branch->id, + ); + + $queue = CareServiceQueue::query() + ->where('organization_id', $this->organization->id) + ->where('context', 'dentistry') + ->where('branch_id', $this->branch->id) + ->firstOrFail(); + + $pointA = CareServicePoint::query() + ->where('service_queue_id', $queue->id) + ->where('kind', 'practitioner') + ->where('ref_id', $dentistA->id) + ->firstOrFail(); + + $pointB = CareServicePoint::query() + ->where('service_queue_id', $queue->id) + ->where('kind', 'practitioner') + ->where('ref_id', $dentistB->id) + ->firstOrFail(); + + // Ticket waits only at Bay 2; admin default desk is Bay 1 (lower ref_id). + CareQueueTicket::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'service_queue_id' => $queue->id, + 'service_point_id' => $pointB->id, + 'ticket_number' => 'DEN001', + 'status' => CareQueueTicket::STATUS_WAITING, + 'priority' => 'walk_in', + 'customer_name' => 'Waiting Dental Patient', + ]); + + $admin = Member::query() + ->where('organization_id', $this->organization->id) + ->where('role', 'hospital_admin') + ->firstOrFail(); + + $result = app(CareQueueBridge::class)->callNext( + $this->organization->fresh(), + 'dentistry', + $this->branch->id, + $admin, + null, + ); + + $this->assertSame('DEN001', $result['ticket']['ticket_number'] ?? null); + $this->assertSame('called', $result['ticket']['status'] ?? null); + $this->assertSame($pointB->uuid, $result['ticket']['assigned_counter']['uuid'] ?? null); + $this->assertNotSame($pointA->uuid, $result['ticket']['assigned_counter']['uuid'] ?? null); + Http::assertNothingSent(); + } }