From 4e910a1db7690ff8a97dc2ac6b4fc5b7b47eea47 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 05:54:47 +0000 Subject: [PATCH] Fix patient queue showing position 1 for every waiter. Specialty demo seed reused slot+1 per module; repair duplicates on queue load and renumber after demo seed so waiting lists show 1..n. Co-authored-by: Cursor --- app/Services/Care/AppointmentService.php | 33 ++++++++++++++++ app/Services/Care/DemoTenantSeeder.php | 48 +++++++++++++++++++++++- tests/Feature/CareAppointmentTest.php | 46 +++++++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/app/Services/Care/AppointmentService.php b/app/Services/Care/AppointmentService.php index 34da2c9..5a7d5f1 100644 --- a/app/Services/Care/AppointmentService.php +++ b/app/Services/Care/AppointmentService.php @@ -73,6 +73,8 @@ class AppointmentService */ public function queue(string $ownerRef, int $branchId, ?int $practitionerId = null): Collection { + $this->repairDuplicateQueuePositions($ownerRef, $branchId); + $query = Appointment::owned($ownerRef) ->where('branch_id', $branchId) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) @@ -381,6 +383,37 @@ class AppointmentService return ($max ?? 0) + 1; } + /** + * Demo specialty seed used to set every module's waiter to position 1. + * Repair duplicates in place so the patient queue shows 1..n. + */ + protected function repairDuplicateQueuePositions(string $ownerRef, int $branchId): void + { + $waiters = Appointment::owned($ownerRef) + ->where('branch_id', $branchId) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->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) { + continue; + } + Appointment::whereKey($appointment->id)->update(['queue_position' => $position]); + } + } + protected function assertTransition(Appointment $appointment, string ...$allowedFrom): void { if (! in_array($appointment->status, $allowedFrom, true)) { diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 0816975..2d06fd2 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -125,7 +125,6 @@ class DemoTenantSeeder $ownerRef, $volumes, ); - $this->assignAllDemoWaiters($organization, $ownerRef, $branches, $practitioners); if (in_array($plan, ['pro', 'enterprise'], true)) { $appointmentCount += $this->seedSpecialtyDemoData( $organization->fresh(), @@ -135,6 +134,8 @@ class DemoTenantSeeder $ownerRef, ); } + $this->assignAllDemoWaiters($organization, $ownerRef, $branches, $practitioners); + $this->renumberWaitingQueuePositions($organization, $ownerRef, $branches); if ($volumes['paid_modules']) { $this->seedPaidModules( @@ -297,6 +298,39 @@ class DemoTenantSeeder } } + /** + * Give each waiting/checked-in appointment a unique 1..n position per branch. + * + * @param list $branches + */ + private function renumberWaitingQueuePositions( + Organization $organization, + string $ownerRef, + array $branches, + ): void { + foreach ($branches as $branch) { + $waiters = Appointment::withTrashed() + ->owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('branch_id', $branch->id) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->orderByRaw('COALESCE(waiting_at, checked_in_at, scheduled_at) asc') + ->orderBy('id') + ->get(); + + foreach ($waiters as $index => $appointment) { + $position = $index + 1; + if ((int) $appointment->queue_position === $position) { + continue; + } + $appointment->forceFill([ + 'queue_position' => $position, + 'deleted_at' => null, + ])->save(); + } + } + } + /** * Ensure the demo tenant has an onboarded organization + owner member. * Safe to call on the SSO request path before redirecting. @@ -751,6 +785,8 @@ class DemoTenantSeeder $count = 0; $catalog = config('care.specialty_modules', []); + /** @var array $nextPositionByBranch */ + $nextPositionByBranch = []; foreach ($catalog as $key => $definition) { $type = (string) ($definition['department_type'] ?? ''); if ($type === '') { @@ -814,6 +850,13 @@ class DemoTenantSeeder ); } + $inQueue = in_array($status, [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN], true); + $queuePosition = null; + if ($inQueue) { + $nextPositionByBranch[$branch->id] = ($nextPositionByBranch[$branch->id] ?? 0) + 1; + $queuePosition = $nextPositionByBranch[$branch->id]; + } + Appointment::withTrashed()->updateOrCreate( ['uuid' => $this->demoUuid("appt|{$ownerRef}|{$token}")], [ @@ -828,11 +871,12 @@ class DemoTenantSeeder 'status' => $status, 'scheduled_at' => $scheduledAt, 'checked_in_at' => $visit?->checked_in_at, + 'waiting_at' => $inQueue ? $scheduledAt->copy()->addMinutes(5) : null, 'completed_at' => $status === Appointment::STATUS_COMPLETED ? $scheduledAt->copy()->addHour() : null, 'cancelled_at' => null, - 'queue_position' => $slot + 1, + 'queue_position' => $queuePosition, 'reason' => $moduleReasons[$slot % count($moduleReasons)], 'created_by' => $ownerRef, 'deleted_at' => null, diff --git a/tests/Feature/CareAppointmentTest.php b/tests/Feature/CareAppointmentTest.php index 14a114e..5149c6f 100644 --- a/tests/Feature/CareAppointmentTest.php +++ b/tests/Feature/CareAppointmentTest.php @@ -209,6 +209,52 @@ class CareAppointmentTest extends TestCase ->assertSee('Patient queue'); } + public function test_queue_repairs_duplicate_positions(): void + { + $second = Patient::create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-2026-00002', + 'first_name' => 'Kofi', + 'last_name' => 'Mensah', + ]); + + foreach ([$this->patient, $second] as $index => $patient) { + Appointment::create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'practitioner_id' => $this->practitioner->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now()->subMinutes(10 - $index), + 'waiting_at' => now()->subMinutes(10 - $index), + 'queue_position' => 1, + 'reason' => $index === 0 ? 'Toothache' : 'Blurry vision', + 'created_by' => $this->user->public_id, + ]); + } + + $this->actingAs($this->user) + ->get(route('care.queue.index', ['branch_id' => $this->branch->id])) + ->assertOk() + ->assertSee('Toothache') + ->assertSee('Blurry vision'); + + $positions = Appointment::query() + ->where('branch_id', $this->branch->id) + ->where('status', Appointment::STATUS_WAITING) + ->orderBy('waiting_at') + ->pluck('queue_position') + ->all(); + + $this->assertSame([1, 2], array_map('intval', $positions)); + } + public function test_api_can_book_and_check_in(): void { Sanctum::actingAs($this->user);