From c5151ad476aada43956217b5d1c0c35813d2050d Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 16:53:52 +0000 Subject: [PATCH] Fix Call next when Care waiters lack Queue tickets. Demo/pre-integration waiting lists showed patients but Call next hit an empty Queue and flashed a red error. Backfill missing tickets on Call next and queue load, and treat empty Call next as info. Co-authored-by: Cursor --- .../Commands/SyncCareQueueTicketsCommand.php | 86 +++++++++ app/Http/Controllers/Care/BillController.php | 2 +- .../Care/InvestigationController.php | 2 +- .../Care/PrescriptionController.php | 2 +- app/Http/Controllers/Care/QueueController.php | 13 +- .../Controllers/Care/SettingsController.php | 19 +- .../Care/SpecialtyModuleController.php | 2 +- app/Services/Care/CareQueueBridge.php | 177 ++++++++++++++++-- app/Services/Care/DemoTenantSeeder.php | 32 ++++ resources/views/partials/flash.blade.php | 8 + tests/Feature/CareQueueBridgeTest.php | 73 ++++++++ 11 files changed, 393 insertions(+), 23 deletions(-) create mode 100644 app/Console/Commands/SyncCareQueueTicketsCommand.php diff --git a/app/Console/Commands/SyncCareQueueTicketsCommand.php b/app/Console/Commands/SyncCareQueueTicketsCommand.php new file mode 100644 index 0000000..be74629 --- /dev/null +++ b/app/Console/Commands/SyncCareQueueTicketsCommand.php @@ -0,0 +1,86 @@ +option('context')); + $contexts = $contextOpt === 'all' + ? array_values(array_filter( + array_keys(CareQueueContexts::departments()), + fn (string $c) => $c !== CareQueueContexts::RECEPTION, + )) + : [$contextOpt]; + + foreach ($contexts as $context) { + if ($context !== CareQueueContexts::RECEPTION && ! CareQueueContexts::isDepartment($context) && ! CareQueueContexts::isSpecialty($context)) { + $this->error("Unknown context: {$context}"); + + return self::FAILURE; + } + } + + $orgQuery = Organization::query(); + if ($this->option('organization')) { + $orgQuery->where('id', (int) $this->option('organization')); + } + + $limit = max(1, (int) $this->option('limit')); + $branchFilter = $this->option('branch') ? (int) $this->option('branch') : null; + $total = 0; + + foreach ($orgQuery->cursor() as $organization) { + if (! $bridge->isEnabled($organization)) { + continue; + } + + $branches = Branch::owned((string) $organization->owner_ref) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->when($branchFilter, fn ($q) => $q->where('id', $branchFilter)) + ->orderBy('id') + ->get(); + + foreach ($branches as $branch) { + foreach ($contexts as $context) { + $issued = $bridge->syncMissingTickets($organization, $context, (int) $branch->id, $limit); + if ($issued > 0) { + $this->info(sprintf( + 'org=%d branch=%d (%s) context=%s issued=%d', + $organization->id, + $branch->id, + $branch->name, + $context, + $issued, + )); + } + $total += $issued; + } + } + } + + $this->info("Done. Issued {$total} ticket(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Care/BillController.php b/app/Http/Controllers/Care/BillController.php index 870e450..70d40df 100644 --- a/app/Http/Controllers/Care/BillController.php +++ b/app/Http/Controllers/Care/BillController.php @@ -72,7 +72,7 @@ class BillController extends Controller $result = $this->queueBridge->callNext($organization, CareQueueContexts::BILLING, $branchId); $ticket = $result['ticket'] ?? null; if (! $ticket) { - return back()->with('error', 'No bills waiting in the billing queue.'); + return back()->with('info', 'No bills waiting in the billing queue.'); } return back()->with('success', 'Called '.$ticket['ticket_number'].'.'); diff --git a/app/Http/Controllers/Care/InvestigationController.php b/app/Http/Controllers/Care/InvestigationController.php index f875d21..9141bbe 100644 --- a/app/Http/Controllers/Care/InvestigationController.php +++ b/app/Http/Controllers/Care/InvestigationController.php @@ -96,7 +96,7 @@ class InvestigationController extends Controller $result = $this->queueBridge->callNext($organization, CareQueueContexts::LABORATORY, $branchId); $ticket = $result['ticket'] ?? null; if (! $ticket) { - return back()->with('error', 'No lab work waiting in the laboratory queue.'); + return back()->with('info', 'No lab work waiting in the laboratory queue.'); } return back()->with('success', 'Called '.$ticket['ticket_number'].'.'); diff --git a/app/Http/Controllers/Care/PrescriptionController.php b/app/Http/Controllers/Care/PrescriptionController.php index 4f827d4..13884eb 100644 --- a/app/Http/Controllers/Care/PrescriptionController.php +++ b/app/Http/Controllers/Care/PrescriptionController.php @@ -98,7 +98,7 @@ class PrescriptionController extends Controller $result = $this->queueBridge->callNext($organization, CareQueueContexts::PHARMACY, $branchId); $ticket = $result['ticket'] ?? null; if (! $ticket) { - return back()->with('error', 'No prescriptions waiting in the pharmacy queue.'); + return back()->with('info', 'No prescriptions waiting in the pharmacy queue.'); } return back()->with('success', 'Called '.$ticket['ticket_number'].'.'); diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index ffc2e3d..1f5da71 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -43,6 +43,13 @@ class QueueController extends Controller $branchId = (int) ($request->input('branch_id') ?: $branchScope ?: $branches->first()?->id); $practitionerId = $request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null; + $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, 25); + $queueIntegration = $this->queueBridge->listMeta($organization, CareQueueContexts::CONSULTATION, $branchId); + } + $queue = $branchId ? $this->appointments->queue($this->ownerRef($request), $branchId, $practitionerId) : collect(); @@ -86,9 +93,7 @@ class QueueController extends Controller 'canManageQueue' => $canManageQueue, 'canConsult' => $canConsult, 'heroStats' => $heroStats, - 'queueIntegration' => $branchId - ? $this->queueBridge->listMeta($organization, CareQueueContexts::CONSULTATION, $branchId) - : ['enabled' => false], + 'queueIntegration' => $queueIntegration, ]); } @@ -106,7 +111,7 @@ class QueueController extends Controller $result = $this->queueBridge->callNext($organization, CareQueueContexts::CONSULTATION, $branchId); $ticket = $result['ticket'] ?? null; if (! $ticket) { - return back()->with('error', 'No patients waiting in the consultation queue.'); + return back()->with('info', 'No patients waiting in the consultation queue.'); } return back()->with('success', 'Called '.$ticket['ticket_number'].' — '.($ticket['customer_name'] ?? 'patient').'.'); diff --git a/app/Http/Controllers/Care/SettingsController.php b/app/Http/Controllers/Care/SettingsController.php index 70931b7..7eb39ff 100644 --- a/app/Http/Controllers/Care/SettingsController.php +++ b/app/Http/Controllers/Care/SettingsController.php @@ -9,6 +9,8 @@ use App\Services\Care\AppointmentPatientNotifier; use App\Services\Care\AuditLogger; use App\Services\Care\CareFeatures; use App\Services\Care\CarePermissions; +use App\Services\Care\CareQueueBridge; +use App\Services\Care\CareQueueContexts; use App\Services\Care\CareQueueProvisioner; use App\Services\Care\PlanService; use App\Services\Billing\PlatformEmailClient; @@ -155,7 +157,22 @@ class SettingsController extends Controller if ($wantsQueue && $queue->configured()) { try { - $provisioner->provisionOrganization($organization->fresh()); + $organization = $organization->fresh(); + $provisioner->provisionOrganization($organization); + // Backfill tickets for patients already waiting when Queue was just linked. + $bridge = app(CareQueueBridge::class); + $branches = Branch::owned($owner) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->pluck('id'); + foreach ($branches as $branchId) { + foreach (array_keys(CareQueueContexts::departments()) as $context) { + if ($context === CareQueueContexts::RECEPTION) { + continue; + } + $bridge->syncMissingTickets($organization, $context, (int) $branchId, 50); + } + } } catch (\Throwable) { // Best-effort; role pages will lazy-ensure on next use. } diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index f5e1694..5023d47 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -97,7 +97,7 @@ class SpecialtyModuleController extends Controller $result = $queueBridge->callNext($organization, $module, $branchId); $ticket = $result['ticket'] ?? null; if (! $ticket) { - return back()->with('error', 'No patients waiting in this specialty queue.'); + return back()->with('info', 'No patients waiting in this specialty queue.'); } return back()->with('success', 'Called '.$ticket['ticket_number'].'.'); diff --git a/app/Services/Care/CareQueueBridge.php b/app/Services/Care/CareQueueBridge.php index ea6cf42..cda88cb 100644 --- a/app/Services/Care/CareQueueBridge.php +++ b/app/Services/Care/CareQueueBridge.php @@ -175,8 +175,30 @@ class CareQueueBridge return $bill->fresh(); } + /** + * Issue Queue tickets for Care waiting entities that were created before + * integration was enabled (or seeded without going through check-in). + * + * @return int Number of tickets successfully issued + */ + public function syncMissingTickets(Organization $organization, string $context, int $branchId, int $limit = 50): int + { + if (! $this->isEnabled($organization) || $branchId < 1 || $limit < 1) { + return 0; + } + + return match (true) { + $context === CareQueueContexts::PHARMACY => $this->syncMissingPrescriptions($organization, $branchId, $limit), + $context === CareQueueContexts::LABORATORY => $this->syncMissingInvestigations($organization, $branchId, $limit), + $context === CareQueueContexts::BILLING => $this->syncMissingBills($organization, $branchId, $limit), + default => $this->syncMissingAppointments($organization, $context, $branchId, $limit), + }; + } + /** * Call the next waiting ticket for a Care page context + branch. + * Backfills missing tickets first so Call next works when Care shows waiters + * that never received a Queue ticket (demo seed / pre-integration check-ins). * * @return array{ticket: ?array, entity: ?Model, resources: ?array} */ @@ -189,20 +211,11 @@ class CareQueueBridge $owner = (string) $organization->owner_ref; - try { - $ticket = $this->queue->callNext( - $owner, - (string) $resources['queue_uuid'], - (string) $resources['counter_uuid'], - ); - } catch (RequestException $e) { - Log::warning('care.queue_call_next_failed', [ - 'context' => $context, - 'branch_id' => $branchId, - 'message' => $e->getMessage(), - ]); - - return ['ticket' => null, 'entity' => null, 'resources' => $resources]; + $ticket = $this->attemptCallNext($owner, $resources); + if (! $ticket) { + // Care may list waiters without Queue tickets — issue one (or a batch) then retry. + $this->syncMissingTickets($organization, $context, $branchId, 25); + $ticket = $this->attemptCallNext($owner, $resources); } if (! $ticket) { @@ -217,6 +230,27 @@ class CareQueueBridge return ['ticket' => $ticket, 'entity' => $entity?->fresh(), 'resources' => $resources]; } + /** + * @param array{queue_uuid?: ?string, counter_uuid?: ?string} $resources + * @return array|null + */ + protected function attemptCallNext(string $owner, array $resources): ?array + { + try { + return $this->queue->callNext( + $owner, + (string) $resources['queue_uuid'], + (string) $resources['counter_uuid'], + ); + } catch (RequestException $e) { + Log::warning('care.queue_call_next_failed', [ + 'message' => $e->getMessage(), + ]); + + return null; + } + } + /** * Mark ticket as serving (Queue "start"). * @@ -383,4 +417,119 @@ class CareQueueBridge ->first(), }; } + + protected function syncMissingAppointments(Organization $organization, string $context, int $branchId, int $limit): int + { + $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']) + ->orderBy('queue_position') + ->orderBy('waiting_at') + ->orderBy('checked_in_at') + ->orderBy('id') + ->limit($limit) + ->get(); + + $issued = 0; + foreach ($appointments as $appointment) { + if ($this->contextForAppointment($organization, $appointment) !== $context) { + continue; + } + + $updated = $this->issueForAppointment($organization, $appointment->fresh(['patient', 'organization'])); + if ($updated && filled($updated->queue_ticket_uuid)) { + $issued++; + } + } + + return $issued; + } + + protected function syncMissingPrescriptions(Organization $organization, int $branchId, int $limit): int + { + $owner = (string) $organization->owner_ref; + $prescriptions = Prescription::owned($owner) + ->where('organization_id', $organization->id) + ->where('status', Prescription::STATUS_ACTIVE) + ->where(function ($q) { + $q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', ''); + }) + ->whereHas('visit', fn ($q) => $q->where('branch_id', $branchId)) + ->with(['patient', 'visit']) + ->orderBy('id') + ->limit($limit) + ->get(); + + $issued = 0; + foreach ($prescriptions as $prescription) { + $updated = $this->issueForPrescription($organization, $prescription); + if ($updated && filled($updated->queue_ticket_uuid)) { + $issued++; + } + } + + return $issued; + } + + protected function syncMissingInvestigations(Organization $organization, int $branchId, int $limit): int + { + $owner = (string) $organization->owner_ref; + $requests = InvestigationRequest::owned($owner) + ->where('organization_id', $organization->id) + ->where('branch_id', $branchId) + ->whereIn('status', [ + InvestigationRequest::STATUS_PENDING, + InvestigationRequest::STATUS_SAMPLE_COLLECTED, + InvestigationRequest::STATUS_IN_PROGRESS, + ]) + ->where(function ($q) { + $q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', ''); + }) + ->with('patient') + ->orderBy('id') + ->limit($limit) + ->get(); + + $issued = 0; + foreach ($requests as $request) { + $updated = $this->issueForInvestigation($organization, $request); + if ($updated && filled($updated->queue_ticket_uuid)) { + $issued++; + } + } + + return $issued; + } + + protected function syncMissingBills(Organization $organization, int $branchId, int $limit): int + { + $owner = (string) $organization->owner_ref; + $bills = Bill::owned($owner) + ->where('organization_id', $organization->id) + ->where('branch_id', $branchId) + ->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL, Bill::STATUS_DRAFT]) + ->where(function ($q) { + $q->whereNull('queue_ticket_uuid')->orWhere('queue_ticket_uuid', ''); + }) + ->with('patient') + ->orderBy('id') + ->limit($limit) + ->get(); + + $issued = 0; + foreach ($bills as $bill) { + $updated = $this->issueForBill($organization, $bill); + if ($updated && filled($updated->queue_ticket_uuid)) { + $issued++; + } + } + + return $issued; + } } diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 522fff6..0918d8f 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -140,6 +140,8 @@ class DemoTenantSeeder ); } + $this->syncQueueTicketsForDemo($organization->fresh(), $branches); + return [ 'organization' => $organization->fresh(), 'branches' => count($branches), @@ -150,6 +152,36 @@ class DemoTenantSeeder }); } + /** + * @param list $branches + */ + private function syncQueueTicketsForDemo(Organization $organization, array $branches): void + { + if (! data_get($organization->settings, 'queue_integration_enabled')) { + return; + } + + try { + $bridge = app(CareQueueBridge::class); + if (! $bridge->isEnabled($organization)) { + return; + } + + foreach ($branches as $branch) { + foreach ([ + CareQueueContexts::CONSULTATION, + CareQueueContexts::PHARMACY, + CareQueueContexts::LABORATORY, + CareQueueContexts::BILLING, + ] as $context) { + $bridge->syncMissingTickets($organization, $context, (int) $branch->id, 100); + } + } + } catch (\Throwable) { + // Demo seed should not fail if Queue API is unreachable. + } + } + public function resetTenant(string $ownerRef): void { $orgIds = DB::table('care_organizations')->where('owner_ref', $ownerRef)->pluck('id'); diff --git a/resources/views/partials/flash.blade.php b/resources/views/partials/flash.blade.php index 657ef02..fdc2a80 100644 --- a/resources/views/partials/flash.blade.php +++ b/resources/views/partials/flash.blade.php @@ -14,6 +14,14 @@ @endif +@if (session('info')) +
+
+

{{ session('info') }}

+
+
+@endif + @if (session('warning'))
diff --git a/tests/Feature/CareQueueBridgeTest.php b/tests/Feature/CareQueueBridgeTest.php index 8f77af8..7b62e95 100644 --- a/tests/Feature/CareQueueBridgeTest.php +++ b/tests/Feature/CareQueueBridgeTest.php @@ -190,4 +190,77 @@ class CareQueueBridgeTest extends TestCase ->assertSee('Call next') ->assertSee('Serve'); } + + public function test_call_next_backfills_missing_ticket_then_calls(): void + { + $ticketSeq = 0; + Http::fake(function (\Illuminate\Http\Client\Request $request) use (&$ticketSeq) { + if (str_contains($request->url(), '/call-next') && $request->method() === 'POST') { + // First call: empty queue; second (after issue): ticket returned. + if ($ticketSeq < 1) { + return Http::response(['data' => null], 200); + } + + return Http::response([ + 'data' => [ + 'uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff', + 'ticket_number' => 'C100', + 'customer_name' => 'Ada Lovelace', + 'status' => 'called', + ], + ], 200); + } + + if (str_contains($request->url(), '/tickets') && $request->method() === 'POST') { + $ticketSeq++; + + return Http::response([ + 'data' => [ + 'uuid' => 'ffffffff-ffff-ffff-ffff-ffffffffffff', + 'ticket_number' => 'C100', + 'status' => 'waiting', + ], + ], 201); + } + + return Http::response(['message' => 'unexpected '.$request->url()], 500); + }); + + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'waiting_at' => now(), + 'queue_position' => 1, + ]); + + $this->actingAs($this->owner) + ->from(route('care.queue.index')) + ->post(route('care.queue.call-next'), ['branch_id' => $this->branch->id]) + ->assertRedirect(route('care.queue.index')) + ->assertSessionHas('success'); + + $this->assertNotNull(Appointment::query()->where('patient_id', $this->patient->id)->value('queue_ticket_uuid')); + Http::assertSent(fn ($r) => str_contains($r->url(), '/tickets') && $r->method() === 'POST'); + Http::assertSent(fn ($r) => str_contains($r->url(), '/call-next')); + } + + public function test_call_next_empty_queue_flashes_info_not_error(): void + { + Http::fake([ + '*/queues/*/call-next*' => Http::response(['data' => null], 200), + ]); + + $this->actingAs($this->owner) + ->from(route('care.queue.index')) + ->post(route('care.queue.call-next'), ['branch_id' => $this->branch->id]) + ->assertRedirect(route('care.queue.index')) + ->assertSessionHas('info', 'No patients waiting in the consultation queue.') + ->assertSessionMissing('error'); + } } +