From e94708d46c0856a4d8b96a6c2ce474dcdb24035f Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 06:12:32 +0000 Subject: [PATCH] Issue Queue tickets for every waiting appointment when integration is on. Patient queue syncs consultation and specialty waiters; specialty contexts now provision desks so check-in/onboarding no longer leaves half the list on local positions only. Co-authored-by: Cursor --- app/Http/Controllers/Care/QueueController.php | 4 +- .../Controllers/Care/SettingsController.php | 11 +- app/Services/Care/CareQueueBridge.php | 109 ++++++++++----- app/Services/Care/CareQueueProvisioner.php | 132 +++++++++++++++--- app/Services/Care/DemoTenantSeeder.php | 23 +-- tests/Feature/CareQueueBridgeTest.php | 99 +++++++++++++ 6 files changed, 312 insertions(+), 66 deletions(-) diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index 014a733..2a856cb 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -39,8 +39,8 @@ class QueueController extends Controller $queueIntegration = ['enabled' => false]; if ($branchId && $this->queueBridge->isEnabled($organization)) { - // Catch up tickets for waiters created before Queue was linked (demo / pre-enable). - $this->queueBridge->syncMissingTickets($organization, CareQueueContexts::CONSULTATION, $branchId, 50); + // Catch up tickets for every waiting appointment (consultation + specialties). + $this->queueBridge->syncMissingAppointmentTickets($organization, $branchId, 100); $queueIntegration = $this->queueBridge->listMeta($organization, CareQueueContexts::CONSULTATION, $branchId); } diff --git a/app/Http/Controllers/Care/SettingsController.php b/app/Http/Controllers/Care/SettingsController.php index 5de75b2..9401980 100644 --- a/app/Http/Controllers/Care/SettingsController.php +++ b/app/Http/Controllers/Care/SettingsController.php @@ -214,8 +214,17 @@ class SettingsController extends Controller ->where('is_active', true) ->pluck('id'); foreach ($branches as $branchId) { + try { + $bridge->syncMissingAppointmentTickets($organization, (int) $branchId, 50); + } catch (\Throwable) { + // Continue other branches. + } foreach (array_keys(CareQueueContexts::departments()) as $context) { - if ($context === CareQueueContexts::RECEPTION) { + if (in_array($context, [ + CareQueueContexts::RECEPTION, + CareQueueContexts::CONSULTATION, + CareQueueContexts::TRIAGE, + ], true)) { continue; } try { diff --git a/app/Services/Care/CareQueueBridge.php b/app/Services/Care/CareQueueBridge.php index 0d30328..749a114 100644 --- a/app/Services/Care/CareQueueBridge.php +++ b/app/Services/Care/CareQueueBridge.php @@ -66,15 +66,16 @@ class CareQueueBridge return $appointment; } - $point = $this->resolveConsultationPoint($organization, $appointment); + $point = $this->resolveAppointmentPoint($organization, $appointment); - if (CareQueueContexts::usesAssignedRouting($context) && ! $point) { + if (CareQueueContexts::usesAssignedRouting($context) && empty($point['counter_uuid'] ?? null)) { $appointment->forceFill([ 'queue_routing_status' => CareQueueContexts::ROUTING_UNRESOLVED, ])->save(); Log::info('care.queue_issue_unresolved_assignment', [ 'appointment_id' => $appointment->id, 'practitioner_id' => $appointment->practitioner_id, + 'context' => $context, ]); return $appointment->fresh(); @@ -280,6 +281,44 @@ class CareQueueBridge }; } + /** + * Issue Queue tickets for every waiting appointment at a branch (consultation + specialties). + */ + public function syncMissingAppointmentTickets(Organization $organization, int $branchId, int $limit = 100): int + { + if (! $this->isEnabled($organization) || $branchId < 1 || $limit < 1) { + return 0; + } + + $owner = (string) $organization->owner_ref; + $appointments = 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_uuid')->orWhere('queue_ticket_uuid', ''); + }) + ->with(['patient', 'organization', 'practitioner', 'visit']) + ->orderBy('queue_position') + ->orderBy('waiting_at') + ->orderBy('checked_in_at') + ->orderBy('id') + ->limit($limit) + ->get(); + + $issued = 0; + foreach ($appointments as $appointment) { + $updated = $this->issueForAppointment($organization, $appointment->fresh([ + 'patient', 'organization', 'practitioner', 'visit', + ])); + if ($updated && filled($updated->queue_ticket_uuid)) { + $issued++; + } + } + + return $issued; + } + /** * @return array{ticket: ?array, entity: ?Model, resources: ?array} */ @@ -405,43 +444,41 @@ class CareQueueBridge /** * @return array|null */ - protected function resolveConsultationPoint(Organization $organization, Appointment $appointment): ?array + protected function resolveAppointmentPoint(Organization $organization, Appointment $appointment): ?array { $branchId = (int) $appointment->branch_id; + $context = $this->contextForAppointment($organization, $appointment); - // Assigned-only consultation requires a doctor — leave Unassigned until one is chosen. - if (! $appointment->practitioner_id) { - return null; + $this->provisioner->ensure($organization, $context, $branchId); + $organization = $organization->fresh() ?? $organization; + + if ($appointment->practitioner_id) { + $point = $this->provisioner->pointForPractitioner( + $organization, + $branchId, + (int) $appointment->practitioner_id, + $context, + ); + if ($point && ! empty($point['counter_uuid'])) { + return $point; + } + + // Refresh points for this context only, then retry the assigned desk. + $this->provisioner->ensure($organization, $context, $branchId); + $organization = $organization->fresh() ?? $organization; + $point = $this->provisioner->pointForPractitioner( + $organization, + $branchId, + (int) $appointment->practitioner_id, + $context, + ); + if ($point && ! empty($point['counter_uuid'])) { + return $point; + } } - $point = $this->provisioner->pointForPractitioner( - $organization, - $branchId, - (int) $appointment->practitioner_id, - ); - if ($point) { - return $point; - } - - // Practitioner assigned but point not provisioned yet — refresh this branch only. - $this->provisioner->ensure($organization, CareQueueContexts::CONSULTATION, $branchId); - - $point = $this->provisioner->pointForPractitioner( - $organization->fresh(), - $branchId, - (int) $appointment->practitioner_id, - ); - if ($point) { - return $point; - } - - // Prefer a desk ticket over leaving waiters Unassigned while counters catch up - // after demo seed / deferred settings provision. - return $this->provisioner->defaultPoint( - $organization->fresh(), - CareQueueContexts::CONSULTATION, - $branchId, - ); + // Prefer a desk ticket over leaving waiters without a Queue number. + return $this->provisioner->defaultPoint($organization, $context, $branchId); } /** @@ -458,7 +495,7 @@ class CareQueueBridge ): ?array { if ($context === CareQueueContexts::CONSULTATION || CareQueueContexts::isSpecialty($context)) { if ($practitionerId) { - return $this->provisioner->pointForPractitioner($organization, $branchId, $practitionerId) + return $this->provisioner->pointForPractitioner($organization, $branchId, $practitionerId, $context) ?? collect($resources['points'] ?? [])->first( fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId ); @@ -473,7 +510,7 @@ class CareQueueBridge }) ->first(); if ($prac) { - return $this->provisioner->pointForPractitioner($organization, $branchId, $prac->id); + return $this->provisioner->pointForPractitioner($organization, $branchId, $prac->id, $context); } } } diff --git a/app/Services/Care/CareQueueProvisioner.php b/app/Services/Care/CareQueueProvisioner.php index d4d0b61..de5b89e 100644 --- a/app/Services/Care/CareQueueProvisioner.php +++ b/app/Services/Care/CareQueueProvisioner.php @@ -198,9 +198,21 @@ class CareQueueProvisioner $owner = (string) $organization->owner_ref; $priorByKey = collect($stub['points'] ?? [])->keyBy(fn ($p) => (string) ($p['external_key'] ?? '')); - $points = match ($context) { - CareQueueContexts::CONSULTATION => $this->practitionerPoints($organization, $branch, $priorByKey), - CareQueueContexts::TRIAGE => $this->memberRolePoints( + $points = match (true) { + $context === CareQueueContexts::CONSULTATION => $this->practitionerPoints( + $organization, + $branch, + CareQueueContexts::CONSULTATION, + $priorByKey, + ), + CareQueueContexts::isSpecialty($context) => $this->practitionerPoints( + $organization, + $branch, + $context, + $priorByKey, + data_get($organization->settings, "specialty_module_provisioning.{$context}.department_ids", []), + ), + $context === CareQueueContexts::TRIAGE => $this->memberRolePoints( $organization, $branch, ['nurse'], @@ -208,7 +220,7 @@ class CareQueueProvisioner 'Nurse station', $priorByKey, ), - CareQueueContexts::PHARMACY => $this->memberRolePoints( + $context === CareQueueContexts::PHARMACY => $this->memberRolePoints( $organization, $branch, ['pharmacist'], @@ -216,7 +228,7 @@ class CareQueueProvisioner 'Pharmacy counter', $priorByKey, ), - CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING => $this->memberRolePoints( + in_array($context, [CareQueueContexts::LABORATORY, CareQueueContexts::IMAGING], true) => $this->memberRolePoints( $organization, $branch, ['lab_technician'], @@ -224,7 +236,7 @@ class CareQueueProvisioner $context === CareQueueContexts::IMAGING ? 'Imaging room' : 'Lab desk', $priorByKey, ), - CareQueueContexts::BILLING => $this->memberRolePoints( + $context === CareQueueContexts::BILLING => $this->memberRolePoints( $organization, $branch, ['cashier'], @@ -232,7 +244,7 @@ class CareQueueProvisioner 'Cashier desk', $priorByKey, ), - CareQueueContexts::RECEPTION => $this->memberRolePoints( + $context === CareQueueContexts::RECEPTION => $this->memberRolePoints( $organization, $branch, ['receptionist'], @@ -264,23 +276,37 @@ class CareQueueProvisioner } /** + * @param list|null $departmentIds * @param \Illuminate\Support\Collection> $priorByKey * @return list> */ - protected function practitionerPoints(Organization $organization, Branch $branch, $priorByKey): array - { + protected function practitionerPoints( + Organization $organization, + Branch $branch, + string $context, + $priorByKey, + ?array $departmentIds = null, + ): array { $practitioners = Practitioner::owned($organization->owner_ref) ->where('organization_id', $organization->id) ->where('branch_id', $branch->id) ->where('is_active', true) + ->when( + is_array($departmentIds) && $departmentIds !== [], + fn ($q) => $q->whereIn('department_id', array_map('intval', $departmentIds)), + ) ->orderBy('name') ->get(); $points = []; $roomIndex = 1; + $roomPrefix = CareQueueContexts::isSpecialty($context) + ? (string) (config("care.specialty_modules.{$context}.label") ?? 'Room') + : 'Consultation Room'; + foreach ($practitioners as $practitioner) { $key = CareQueueContexts::pointExternalKey( - CareQueueContexts::CONSULTATION, + $context, $branch->id, 'practitioner', $practitioner->id, @@ -288,7 +314,7 @@ class CareQueueProvisioner $prior = $priorByKey->get($key, []); $room = trim((string) ($practitioner->room ?? '')); if ($room === '') { - $room = 'Consultation Room '.$roomIndex; + $room = $roomPrefix.' '.$roomIndex; } $roomIndex++; @@ -407,13 +433,17 @@ class CareQueueProvisioner } /** - * Resolve a service point for a practitioner (consultation) or member staff_ref. + * Resolve a service point for a practitioner on a queue context. * * @return array|null */ - public function pointForPractitioner(Organization $organization, int $branchId, int $practitionerId): ?array - { - $resources = $this->resourcesFor($organization, CareQueueContexts::CONSULTATION, $branchId); + public function pointForPractitioner( + Organization $organization, + int $branchId, + int $practitionerId, + string $context = CareQueueContexts::CONSULTATION, + ): ?array { + $resources = $this->resourcesFor($organization, $context, $branchId); if (! $resources) { return null; } @@ -495,7 +525,11 @@ class CareQueueProvisioner if ($existing && ! empty($existing['queue_uuid']) && ( ! empty($existing['points']) || ! empty($existing['counter_uuid']) ) && $modeMatches) { - return $existing; + // Specialty module stubs often only store a legacy counter — still provision + // practitioner points so check-in / sync can assign real desks. + if (! CareQueueContexts::isSpecialty($context) || ! empty($existing['points'])) { + return $existing; + } } if (! $this->shouldProvision($organization)) { @@ -503,7 +537,8 @@ class CareQueueProvisioner } if (CareQueueContexts::isSpecialty($context)) { - return $existing; + return $this->provisionSpecialtyContextBranch($organization->fresh(), $context, $branchId) + ?? $existing; } if (! CareQueueContexts::isDepartment($context)) { @@ -515,6 +550,69 @@ class CareQueueProvisioner return $this->resourcesFor($organization->fresh(), $context, $branchId); } + /** + * Provision a single specialty queue for one branch (idempotent). + * + * @return array|null + */ + public function provisionSpecialtyContextBranch(Organization $organization, string $context, int $branchId): ?array + { + if (! CareQueueContexts::isSpecialty($context) || ! $this->shouldProvision($organization)) { + return $this->resourcesFor($organization, $context, $branchId); + } + + $definition = config("care.specialty_modules.{$context}"); + if (! is_array($definition)) { + return null; + } + + $branch = Branch::owned((string) $organization->owner_ref) + ->where('organization_id', $organization->id) + ->whereKey($branchId) + ->first(); + if (! $branch) { + return null; + } + + $meta = [ + 'name' => (string) ($definition['queue_name'] ?? $definition['label'] ?? $context), + 'prefix' => (string) ($definition['queue_prefix'] ?? strtoupper(substr($context, 0, 3))), + 'routing_mode' => 'assigned_only', + 'type' => 'consultation', + ]; + + $provisioning = (array) data_get($organization->settings, 'specialty_module_provisioning', []); + $record = is_array($provisioning[$context] ?? null) ? $provisioning[$context] : []; + $queues = is_array($record['queues'] ?? null) ? $record['queues'] : []; + $byBranch = collect($queues)->keyBy(fn ($q) => (string) ($q['branch_id'] ?? '')); + $prior = $byBranch->get((string) $branch->id, []); + if (! is_array($prior)) { + $prior = []; + } + $prior['module'] = $context; + + $stub = $this->provisionBranchDepartment( + $organization, + (string) $organization->owner_ref, + $context, + $meta, + $branch, + $prior, + ); + $byBranch->put((string) $branch->id, $stub); + + $provisioning[$context] = array_merge($record, [ + 'active' => true, + 'queues' => $byBranch->values()->all(), + ]); + + $settings = $organization->settings ?? []; + $settings['specialty_module_provisioning'] = $provisioning; + $organization->update(['settings' => $settings]); + + return $stub; + } + /** * Provision a single department queue for one branch (idempotent). */ diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 2d06fd2..8225a76 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -186,17 +186,20 @@ class DemoTenantSeeder return; } - // Scoped per branch/context (not full-org) so demos finish quickly while - // still leaving waiters with real tickets instead of "Unassigned". - $contexts = [ - CareQueueContexts::CONSULTATION, - CareQueueContexts::PHARMACY, - CareQueueContexts::LABORATORY, - CareQueueContexts::BILLING, - ]; - + // Scoped per branch (not full-org) so demos finish quickly while + // still leaving waiters with real tickets instead of local positions only. foreach ($branches as $branch) { - foreach ($contexts as $context) { + try { + $bridge->syncMissingAppointmentTickets($organization, (int) $branch->id, 100); + } catch (\Throwable) { + // Continue — page load will retry. + } + + foreach ([ + CareQueueContexts::PHARMACY, + CareQueueContexts::LABORATORY, + CareQueueContexts::BILLING, + ] as $context) { try { $bridge->syncMissingTickets($organization, $context, (int) $branch->id, 50); } catch (\Throwable) { diff --git a/tests/Feature/CareQueueBridgeTest.php b/tests/Feature/CareQueueBridgeTest.php index 8bb28e8..c08bcf2 100644 --- a/tests/Feature/CareQueueBridgeTest.php +++ b/tests/Feature/CareQueueBridgeTest.php @@ -368,5 +368,104 @@ class CareQueueBridgeTest extends TestCase ->assertSee('Call again') ->assertSee('C055'); } + + public function test_patient_queue_sync_issues_specialty_tickets(): void + { + $department = \App\Models\Department::create([ + 'owner_ref' => $this->owner->public_id, + 'branch_id' => $this->branch->id, + 'name' => 'Dentistry', + 'type' => 'dental', + 'is_active' => true, + ]); + + $practitioner = \App\Models\Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'department_id' => $department->id, + 'name' => 'Dentistry Clinic', + 'room' => 'Dental Bay 1', + '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' => true, + 'routing_mode' => 'assigned_only', + 'queue_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd', + 'counter_uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', + 'queue_external_key' => CareQueueContexts::queueExternalKey('dentistry', $this->branch->id), + 'counter_external_key' => CareQueueContexts::counterExternalKey('dentistry', $this->branch->id), + 'points' => [[ + 'kind' => 'practitioner', + 'ref_id' => $practitioner->id, + 'destination' => 'Dental Bay 1', + 'staff_display_name' => 'Dentistry Clinic', + 'counter_uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', + 'synced' => true, + ]], + ]], + ], + ]; + $this->organization->update(['settings' => $settings]); + + 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' => $practitioner->id, + 'department_id' => $department->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'waiting_at' => now(), + 'queue_position' => 1, + 'reason' => 'Toothache', + ]); + + Http::fake(function (\Illuminate\Http\Client\Request $request) { + if (str_contains($request->url(), '/tickets') && $request->method() === 'POST') { + $this->assertSame('dddddddd-dddd-dddd-dddd-dddddddddddd', $request['service_queue_id'] ?? null); + $this->assertSame('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', $request['assigned_counter_id'] ?? null); + + return Http::response([ + 'data' => [ + 'uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff', + 'ticket_number' => 'DEN001', + 'status' => 'waiting', + 'assigned_counter' => ['uuid' => 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'], + 'destination' => 'Dental Bay 1', + 'staff_display_name' => 'Dentistry Clinic', + ], + ], 201); + } + + return Http::response(['data' => []], 200); + }); + + $this->actingAs($this->owner) + ->get(route('care.queue.index', ['branch_id' => $this->branch->id])) + ->assertOk() + ->assertSee('DEN001') + ->assertSee('Toothache'); + + $this->assertDatabaseHas('care_appointments', [ + 'patient_id' => $this->patient->id, + 'queue_ticket_number' => 'DEN001', + ]); + } }