From a5bbbe23b19491388166b954de5a159a7be66660 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 19:50:03 +0000 Subject: [PATCH] Speed up Queue board loads by dropping sync and gate side effects. Stop issuing missing tickets and refreshing financial gates on every GET; eager-load advances, cap board rows, and cache specialty department maps. Co-authored-by: Cursor --- app/Http/Controllers/Care/QueueController.php | 78 ++++-- .../Care/SpecialtyModuleController.php | 2 +- app/Models/Visit.php | 16 ++ app/Services/Care/AppointmentService.php | 80 ++++-- app/Services/Care/CareQueueBridge.php | 31 ++- app/Services/Care/Workflow/WorkflowEngine.php | 11 + .../Care/Workflow/WorkflowQueueGate.php | 79 ++++-- resources/views/care/queue/index.blade.php | 20 +- .../Feature/CareQueueBoardPerformanceTest.php | 263 ++++++++++++++++++ tests/Feature/CareQueueBridgeTest.php | 8 + 10 files changed, 512 insertions(+), 76 deletions(-) create mode 100644 tests/Feature/CareQueueBoardPerformanceTest.php diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index 603aac6..0c97f84 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -61,37 +61,32 @@ class QueueController extends Controller } if ($this->queueBridge->isEnabled($organization)) { - $syncBranches = $lockToPractitioner - ? Practitioner::owned($owner) - ->whereIn('id', $practitionerScope ?: [0]) - ->pluck('branch_id') - ->map(fn ($id) => (int) $id) - ->unique() - ->filter() - ->values() - ->all() - : array_filter([$branchId]); - - foreach ($syncBranches as $syncBranchId) { - $this->queueBridge->syncMissingAppointmentTickets($organization, $syncBranchId, 100); - } - + // Ticket backfill belongs on check-in / Call next / care:queue-sync-tickets — + // not on every board GET (can issue up to 100 tickets with provisioner work). if ($branchId > 0) { $queueIntegration = $this->queueBridge->listMeta($organization, $queueContext, $branchId); - } elseif ($syncBranches !== []) { - $queueIntegration = $this->queueBridge->listMeta( - $organization, - $queueContext, - (int) $syncBranches[0], - ); + } elseif ($lockToPractitioner && $practitionerScope) { + $firstBranch = (int) Practitioner::owned($owner) + ->whereIn('id', $practitionerScope) + ->value('branch_id'); + if ($firstBranch > 0) { + $queueIntegration = $this->queueBridge->listMeta( + $organization, + $queueContext, + $firstBranch, + ); + } } } + $boardLimit = AppointmentService::QUEUE_BOARD_LIMIT; + if ($lockToPractitioner) { $queue = $this->appointments->queueForPractitioners( $owner, $practitionerScope ?? [], $branchId > 0 ? $branchId : null, + $boardLimit, ); $inConsultation = Appointment::owned($owner) ->where('organization_id', $organization->id) @@ -99,10 +94,11 @@ class QueueController extends Controller ->whereIn('practitioner_id', $practitionerScope ?: [0]) ->with(['patient', 'practitioner', 'consultation', 'visit', 'organization']) ->orderBy('started_at') + ->limit($boardLimit) ->get(); } else { $queue = $branchId - ? $this->appointments->queue($owner, $branchId, $practitionerId) + ? $this->appointments->queue($owner, $branchId, $practitionerId, true, null, $boardLimit) : collect(); $inConsultation = Appointment::owned($owner) ->where('organization_id', $organization->id) @@ -111,6 +107,7 @@ class QueueController extends Controller ->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId)) ->with(['patient', 'practitioner', 'consultation', 'visit', 'organization']) ->orderBy('started_at') + ->limit($boardLimit) ->get(); } @@ -126,6 +123,36 @@ class QueueController extends Controller $todayQuery->where('branch_id', $branchId); } + $waitingCountQuery = Appointment::owned($owner) + ->where('organization_id', $organization->id) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]); + if ($lockToPractitioner) { + $waitingCountQuery->whereIn('practitioner_id', $practitionerScope ?: [0]); + if ($branchId > 0) { + $waitingCountQuery->where('branch_id', $branchId); + } + } elseif ($branchId) { + $waitingCountQuery->where('branch_id', $branchId); + if ($practitionerId) { + $waitingCountQuery->where(function ($q) use ($practitionerId) { + $q->where('practitioner_id', $practitionerId)->orWhereNull('practitioner_id'); + }); + } + } else { + $waitingCountQuery->whereRaw('1 = 0'); + } + + $inCareCountQuery = Appointment::owned($owner) + ->where('organization_id', $organization->id) + ->where('status', Appointment::STATUS_IN_CONSULTATION); + if ($lockToPractitioner) { + $inCareCountQuery->whereIn('practitioner_id', $practitionerScope ?: [0]); + } else { + $inCareCountQuery + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId)); + } + $doneQuery = Appointment::owned($owner) ->where('organization_id', $organization->id) ->where('status', Appointment::STATUS_COMPLETED) @@ -145,11 +172,13 @@ class QueueController extends Controller ->get(); $heroStats = [ - 'waiting' => $queue->count(), - 'in_consultation' => $inConsultation->count(), + 'waiting' => $waitingCountQuery->count(), + 'in_consultation' => $inCareCountQuery->count(), 'today' => $todayQuery->count(), ]; + $specialtyDepartmentMap = $this->queueBridge->specialtyDepartmentMap($organization); + return view('care.queue.index', [ 'organization' => $organization, 'branches' => $branches, @@ -174,6 +203,7 @@ class QueueController extends Controller 'queueIntegration' => $queueIntegration, 'queueContext' => $queueContext, 'specialtyLabel' => $specialtyLabel, + 'specialtyDepartmentMap' => $specialtyDepartmentMap, ]); } diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index d8e7ba4..18e46ce 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -623,7 +623,7 @@ class SpecialtyModuleController extends Controller $practitionerScope !== null, fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]), ) - ->with(['patient', 'practitioner', 'department', 'visit']) + ->with(['patient', 'practitioner', 'department', 'visit.currentStageAdvance.stage']) ->orderBy('queue_position') ->orderBy('checked_in_at') ->limit(50) diff --git a/app/Models/Visit.php b/app/Models/Visit.php index 48d1a3c..66f77da 100644 --- a/app/Models/Visit.php +++ b/app/Models/Visit.php @@ -94,6 +94,22 @@ class Visit extends Model return $this->hasMany(VisitStageAdvance::class, 'visit_id'); } + /** + * Active or blocked advance for the visit's current workflow stage. + * Prefer this for eager-loaded queue board filtering (avoids N+1 currentAdvance queries). + */ + public function currentStageAdvance(): HasOne + { + return $this->hasOne(VisitStageAdvance::class, 'visit_id') + ->ofMany( + ['id' => 'max'], + fn ($query) => $query->whereIn('status', [ + VisitStageAdvance::STATUS_ACTIVE, + VisitStageAdvance::STATUS_BLOCKED, + ]), + ); + } + public function financialObligations(): HasMany { return $this->hasMany(FinancialObligation::class, 'visit_id'); diff --git a/app/Services/Care/AppointmentService.php b/app/Services/Care/AppointmentService.php index 195eae8..b9fcddc 100644 --- a/app/Services/Care/AppointmentService.php +++ b/app/Services/Care/AppointmentService.php @@ -83,6 +83,9 @@ class AppointmentService return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString(); } + /** Max waiters / in-care rows rendered on the patient-flow board. */ + public const QUEUE_BOARD_LIMIT = 50; + /** * @param list|null $practitionerIds * @return Collection @@ -93,20 +96,29 @@ class AppointmentService ?int $practitionerId = null, bool $includeUnassigned = true, ?array $practitionerIds = null, + ?int $limit = null, ): Collection { if ($practitionerIds !== null) { - return $this->queueForPractitioners($ownerRef, $practitionerIds, $branchId > 0 ? $branchId : null); + return $this->queueForPractitioners( + $ownerRef, + $practitionerIds, + $branchId > 0 ? $branchId : null, + $limit, + ); } $this->repairDuplicateQueuePositions($ownerRef, $branchId); + $limit = $limit ?? self::QUEUE_BOARD_LIMIT; + $query = Appointment::owned($ownerRef) ->where('branch_id', $branchId) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) - ->with(['patient', 'practitioner', 'visit', 'organization']) + ->with($this->queueEagerLoads()) ->orderBy('queue_position') ->orderBy('waiting_at') - ->orderBy('checked_in_at'); + ->orderBy('checked_in_at') + ->limit($limit); if ($practitionerId !== null) { $query->where(function (Builder $q) use ($practitionerId, $includeUnassigned) { @@ -124,7 +136,7 @@ class AppointmentService return true; } - return $this->workflowGate->canRelease( + return $this->workflowGate->isReleasedForDisplay( $organization, $appointment->visit, $this->queueBridge->contextForAppointment($organization, $appointment), @@ -139,20 +151,27 @@ class AppointmentService * @param list $practitionerIds * @return Collection */ - public function queueForPractitioners(string $ownerRef, array $practitionerIds, ?int $branchId = null): Collection - { + public function queueForPractitioners( + string $ownerRef, + array $practitionerIds, + ?int $branchId = null, + ?int $limit = null, + ): Collection { if ($practitionerIds === []) { return new Collection; } + $limit = $limit ?? self::QUEUE_BOARD_LIMIT; + $query = Appointment::owned($ownerRef) ->whereIn('practitioner_id', $practitionerIds) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) - ->with(['patient', 'practitioner', 'visit', 'organization']) + ->with($this->queueEagerLoads()) ->orderBy('queue_position') ->orderBy('waiting_at') - ->orderBy('checked_in_at'); + ->orderBy('checked_in_at') + ->limit($limit); $practitioners = Practitioner::owned($ownerRef) ->whereIn('id', $practitionerIds) @@ -192,7 +211,7 @@ class AppointmentService return false; } - return $this->workflowGate->canRelease( + return $this->workflowGate->isReleasedForDisplay( $organization, $appointment->visit, $context, @@ -203,6 +222,19 @@ class AppointmentService ); } + /** + * @return list> + */ + protected function queueEagerLoads(): array + { + return [ + 'patient', + 'practitioner', + 'organization', + 'visit.currentStageAdvance.stage', + ]; + } + /** * @param array $data */ @@ -484,25 +516,33 @@ class AppointmentService /** * Demo specialty seed used to set every module's waiter to position 1. * Repair duplicates in place so the patient queue shows 1..n. + * Cheap existence check first so healthy queues skip the full rewrite. */ protected function repairDuplicateQueuePositions(string $ownerRef, int $branchId): void { - $waiters = Appointment::owned($ownerRef) + $base = Appointment::owned($ownerRef) ->where('branch_id', $branchId) - ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]); + + $waiterCount = (clone $base)->count(); + if ($waiterCount < 2) { + return; + } + + $hasNulls = (clone $base)->whereNull('queue_position')->exists(); + $distinctPositions = (int) (clone $base) + ->whereNotNull('queue_position') + ->selectRaw('count(distinct queue_position) as aggregate') + ->value('aggregate'); + if (! $hasNulls && $distinctPositions === $waiterCount) { + return; + } + + $waiters = (clone $base) ->orderByRaw('COALESCE(waiting_at, checked_in_at, scheduled_at) asc') ->orderBy('id') ->get(['id', 'queue_position']); - if ($waiters->count() < 2) { - return; - } - - $positions = $waiters->pluck('queue_position'); - if ($positions->filter(fn ($p) => $p !== null)->unique()->count() === $waiters->count()) { - return; - } - foreach ($waiters as $index => $appointment) { $position = $index + 1; if ((int) $appointment->queue_position === $position) { diff --git a/app/Services/Care/CareQueueBridge.php b/app/Services/Care/CareQueueBridge.php index 5d98d63..9afef25 100644 --- a/app/Services/Care/CareQueueBridge.php +++ b/app/Services/Care/CareQueueBridge.php @@ -19,6 +19,9 @@ use Illuminate\Support\Facades\Log; */ class CareQueueBridge { + /** @var array> organization_id => [department_id => specialty_key] */ + protected array $specialtyDepartmentMaps = []; + public function __construct( protected CareQueueEngine $engine, protected CareQueueProvisioner $provisioner, @@ -49,14 +52,34 @@ class CareQueueBridge return CareQueueContexts::CONSULTATION; } + return $this->specialtyDepartmentMap($organization)[$departmentId] + ?? CareQueueContexts::CONSULTATION; + } + + /** + * Department id → specialty module key for this org (request-scoped cache). + * + * @return array + */ + public function specialtyDepartmentMap(Organization $organization): array + { + $orgId = (int) $organization->id; + if (isset($this->specialtyDepartmentMaps[$orgId])) { + return $this->specialtyDepartmentMaps[$orgId]; + } + + $map = []; foreach ($this->specialties->enabledKeys($organization) as $key) { $deptIds = data_get($organization->settings, "specialty_module_provisioning.{$key}.department_ids", []); - if (is_array($deptIds) && in_array($departmentId, array_map('intval', $deptIds), true)) { - return $key; + if (! is_array($deptIds)) { + continue; + } + foreach ($deptIds as $deptId) { + $map[(int) $deptId] = $key; } } - return CareQueueContexts::CONSULTATION; + return $this->specialtyDepartmentMaps[$orgId] = $map; } public function contextForPractitioner(Organization $organization, ?Practitioner $practitioner): string @@ -496,7 +519,7 @@ class CareQueueBridge ]; } - $resources = $this->provisioner->ensure($organization->fresh(), $context, $branchId); + $resources = $this->provisioner->ensure($organization, $context, $branchId); return [ 'enabled' => true, diff --git a/app/Services/Care/Workflow/WorkflowEngine.php b/app/Services/Care/Workflow/WorkflowEngine.php index daca071..f5321c4 100644 --- a/app/Services/Care/Workflow/WorkflowEngine.php +++ b/app/Services/Care/Workflow/WorkflowEngine.php @@ -64,6 +64,17 @@ class WorkflowEngine public function currentAdvance(Visit $visit): ?VisitStageAdvance { + if ($visit->relationLoaded('currentStageAdvance')) { + return $visit->currentStageAdvance; + } + + if ($visit->relationLoaded('stageAdvances')) { + return $visit->stageAdvances + ->whereIn('status', [VisitStageAdvance::STATUS_ACTIVE, VisitStageAdvance::STATUS_BLOCKED]) + ->sortByDesc('id') + ->first(); + } + return VisitStageAdvance::query() ->where('visit_id', $visit->id) ->whereIn('status', [VisitStageAdvance::STATUS_ACTIVE, VisitStageAdvance::STATUS_BLOCKED]) diff --git a/app/Services/Care/Workflow/WorkflowQueueGate.php b/app/Services/Care/Workflow/WorkflowQueueGate.php index 8b128e7..eb4615f 100644 --- a/app/Services/Care/Workflow/WorkflowQueueGate.php +++ b/app/Services/Care/Workflow/WorkflowQueueGate.php @@ -4,6 +4,7 @@ namespace App\Services\Care\Workflow; use App\Models\Organization; use App\Models\Visit; +use App\Models\VisitStageAdvance; use App\Models\WorkflowStage; use App\Services\Care\CareFeatures; use App\Services\Care\CareQueueContexts; @@ -24,6 +25,10 @@ class WorkflowQueueGate return $this->features->enabled($organization, CareFeatures::WORKFLOW_ENGINE); } + /** + * Mutating gate used when issuing tickets or starting encounters. + * May start a workflow or refresh financial gate state. + */ public function canRelease( Organization $organization, ?Visit $visit, @@ -36,29 +41,31 @@ class WorkflowQueueGate $advance = $this->engine->currentAdvance($visit) ? $this->engine->refreshGate($visit) : $this->engine->start($visit); - $stage = $advance?->stage; - // An enabled org without a materialized workflow keeps legacy behavior. - if ($stage === null) { + return $this->evaluateAdvance($organization, $visit, $advance, $queueContext, mutate: true); + } + + /** + * Read-only gate for waiting-list / board rendering. + * Does not start workflows, create obligations, or refresh gate rows. + */ + public function isReleasedForDisplay( + Organization $organization, + ?Visit $visit, + string $queueContext, + ): bool { + if (! $this->enabled($organization) || $visit === null) { return true; } - // Pay-before-service holds belong on the cashier / billing line, not the - // clinical service queue for the current stage. - if ($queueContext === CareQueueContexts::BILLING - && $this->isAwaitingFinancialGate($organization, $visit, $stage)) { + $advance = $this->engine->currentAdvance($visit); + + // No materialized advance yet — keep legacy visibility (do not start on GET). + if ($advance === null) { return true; } - if (! $this->contextsMatch($stage->queue_context, $stage->type, $queueContext)) { - return false; - } - - if (! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) { - return true; - } - - return $this->engine->isQueueReleasable($visit); + return $this->evaluateAdvance($organization, $visit, $advance, $queueContext, mutate: false); } /** @@ -81,6 +88,46 @@ class WorkflowQueueGate return ! $this->engine->isQueueReleasable($visit); } + protected function evaluateAdvance( + Organization $organization, + Visit $visit, + ?VisitStageAdvance $advance, + string $queueContext, + bool $mutate, + ): bool { + $stage = $advance?->stage; + + // An enabled org without a materialized workflow keeps legacy behavior. + if ($stage === null) { + return true; + } + + // Pay-before-service holds belong on the cashier / billing line, not the + // clinical service queue for the current stage. + if ($queueContext === CareQueueContexts::BILLING) { + if ($mutate + ? $this->isAwaitingFinancialGate($organization, $visit, $stage) + : ($advance->isBlocked() && $stage->gatesBeforeService())) { + return true; + } + } + + if (! $this->contextsMatch($stage->queue_context, $stage->type, $queueContext)) { + return false; + } + + if (! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) { + return true; + } + + // Display path trusts advance status (payment clear paths refresh the gate). + if (! $mutate) { + return ! $advance->isBlocked(); + } + + return $this->engine->isQueueReleasable($visit); + } + protected function contextsMatch(?string $stageContext, string $stageType, string $queueContext): bool { if ($stageContext === $queueContext) { diff --git a/resources/views/care/queue/index.blade.php b/resources/views/care/queue/index.blade.php index 61a77b2..0911410 100644 --- a/resources/views/care/queue/index.blade.php +++ b/resources/views/care/queue/index.blade.php @@ -1,6 +1,6 @@ @php $specialtyLabel = $specialtyLabel ?? null; - $queueBridge = app(\App\Services\Care\CareQueueBridge::class); + $specialtyDepartmentMap = $specialtyDepartmentMap ?? []; @endphp
@@ -40,16 +40,14 @@ 'queueCallNextParams' => array_filter(['practitioner_id' => $practitionerId]), 'startRouteName' => 'care.queue.start', 'inCareLabel' => $specialtyLabel ? 'In care' : 'In consultation', - 'openInCareUrl' => function ($appointment) use ($queueBridge) { - $organization = $appointment->organization; - if ($organization && $appointment->visit) { - $context = $queueBridge->contextForAppointment($organization, $appointment); - if ($context !== \App\Services\Care\CareQueueContexts::CONSULTATION) { - return route('care.specialty.workspace', [ - 'module' => $context, - 'visit' => $appointment->visit, - ]); - } + 'openInCareUrl' => function ($appointment) use ($specialtyDepartmentMap) { + $deptId = (int) ($appointment->department_id ?? 0); + $module = $deptId > 0 ? ($specialtyDepartmentMap[$deptId] ?? null) : null; + if ($module && $appointment->visit) { + return route('care.specialty.workspace', [ + 'module' => $module, + 'visit' => $appointment->visit, + ]); } return $appointment->consultation diff --git a/tests/Feature/CareQueueBoardPerformanceTest.php b/tests/Feature/CareQueueBoardPerformanceTest.php new file mode 100644 index 0000000..342af8f --- /dev/null +++ b/tests/Feature/CareQueueBoardPerformanceTest.php @@ -0,0 +1,263 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'queue-perf-owner', + 'name' => 'Owner', + 'email' => 'queue-perf@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Perf Clinic', + 'slug' => 'perf-clinic', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'clinic', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'queue_integration_enabled' => true, + 'rollout' => [ + CareFeatures::WORKFLOW_ENGINE => true, + CareFeatures::FINANCIAL_GATES => true, + ], + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'receptionist', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + } + + public function test_queue_index_does_not_backfill_tickets_or_start_workflows(): void + { + $workflow = FacilityWorkflow::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Default', + 'is_active' => true, + ]); + WorkflowStage::create([ + 'owner_ref' => $this->owner->public_id, + 'workflow_id' => $workflow->id, + 'code' => 'consultation', + 'name' => 'Consultation', + 'type' => 'consultation', + 'queue_context' => CareQueueContexts::CONSULTATION, + 'sort_order' => 1, + 'requires_payment' => false, + ]); + + $seedWaiters = function (int $count, int $offset = 0): void { + for ($i = 0; $i < $count; $i++) { + $n = $offset + $i; + $patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-PERF-'.$n, + 'first_name' => 'Pat', + 'last_name' => (string) $n, + ]); + $visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'waiting_at' => now()->subMinutes(100 - $n), + 'queue_position' => $n + 1, + ]); + } + }; + + $seedWaiters(5); + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->actingAs($this->owner) + ->get(route('care.queue.index', ['branch_id' => $this->branch->id])) + ->assertOk(); + $queriesWith5 = count(DB::getQueryLog()); + DB::disableQueryLog(); + + $seedWaiters(20, 5); + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->actingAs($this->owner) + ->get(route('care.queue.index', ['branch_id' => $this->branch->id])) + ->assertOk() + ->assertSee('Patient flow'); + $log = collect(DB::getQueryLog()); + DB::disableQueryLog(); + $queriesWith25 = $log->count(); + + $this->assertSame(0, Appointment::query()->whereNotNull('queue_ticket_uuid')->where('queue_ticket_uuid', '!=', '')->count()); + $this->assertSame(0, VisitStageAdvance::query()->count(), 'Board GET must not start workflows'); + + // Adding 20 waiters must not add ~N queries (gate refresh / ticket sync / context loops). + $delta = $queriesWith25 - $queriesWith5; + $this->assertLessThan( + 25, + $delta, + "Query delta for +20 waiters should be small (eager loads), got {$delta} (5={$queriesWith5}, 25={$queriesWith25})", + ); + + $advanceLookups = $log->filter( + fn ($q) => str_contains(strtolower($q['query']), 'care_visit_stage_advances'), + )->count(); + $this->assertLessThanOrEqual( + 3, + $advanceLookups, + "visit_stage_advances lookups should be eager-loaded, got {$advanceLookups}", + ); + } + + public function test_queue_board_caps_waiting_list_size(): void + { + $limit = AppointmentService::QUEUE_BOARD_LIMIT; + + for ($i = 0; $i < $limit + 15; $i++) { + $patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-CAP-'.$i, + 'first_name' => 'Cap', + 'last_name' => (string) $i, + ]); + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'waiting_at' => now()->subMinutes($limit + 15 - $i), + 'queue_position' => $i + 1, + ]); + } + + $queue = app(AppointmentService::class)->queue( + $this->owner->public_id, + $this->branch->id, + ); + + $this->assertCount($limit, $queue); + $this->assertSame(1, (int) $queue->first()->queue_position); + } + + public function test_display_gate_hides_blocked_visits_without_refresh(): void + { + $workflow = FacilityWorkflow::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Gated', + 'is_active' => true, + ]); + $stage = WorkflowStage::create([ + 'owner_ref' => $this->owner->public_id, + 'workflow_id' => $workflow->id, + 'code' => 'consultation', + 'name' => 'Consultation', + 'type' => 'consultation', + 'queue_context' => CareQueueContexts::CONSULTATION, + 'sort_order' => 1, + 'requires_payment' => true, + 'payment_timing' => 'before', + 'default_amount_minor' => 1000, + ]); + + $patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-GATE', + 'first_name' => 'Blocked', + 'last_name' => 'Patient', + ]); + $visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + VisitStageAdvance::create([ + 'owner_ref' => $this->owner->public_id, + 'visit_id' => $visit->id, + 'workflow_id' => $workflow->id, + 'workflow_stage_id' => $stage->id, + 'stage_code' => 'consultation', + 'status' => VisitStageAdvance::STATUS_BLOCKED, + 'blocked_reason' => 'awaiting_payment', + 'entered_at' => now(), + ]); + + $gate = app(WorkflowQueueGate::class); + $this->assertFalse($gate->isReleasedForDisplay( + $this->organization, + $visit->fresh('currentStageAdvance.stage'), + CareQueueContexts::CONSULTATION, + )); + } +} diff --git a/tests/Feature/CareQueueBridgeTest.php b/tests/Feature/CareQueueBridgeTest.php index 858fda7..2c3c0be 100644 --- a/tests/Feature/CareQueueBridgeTest.php +++ b/tests/Feature/CareQueueBridgeTest.php @@ -367,12 +367,20 @@ class CareQueueBridgeTest extends TestCase 'reason' => 'Toothache', ]); + // Board GET must remain read-only; ticket backfill is explicit (Call next / artisan). $this->actingAs($this->owner) ->get(route('care.queue.index', ['branch_id' => $this->branch->id])) ->assertOk() ->assertSee('Toothache'); $appointment = Appointment::query()->where('patient_id', $this->patient->id)->first(); + $this->assertNull($appointment?->queue_ticket_uuid); + + $issued = app(\App\Services\Care\CareQueueBridge::class) + ->syncMissingAppointmentTickets($this->organization->fresh(), (int) $this->branch->id, 10); + $this->assertSame(1, $issued); + + $appointment = $appointment->fresh(); $this->assertNotNull($appointment?->queue_ticket_uuid); $this->assertStringStartsWith('DEN', (string) $appointment->queue_ticket_number); $this->assertDatabaseHas('care_service_queues', [