withoutMiddleware(EnsurePlatformSession::class); $this->owner = User::create([ 'public_id' => 'bridge-owner', 'name' => 'Owner', 'email' => 'bridge@example.com', ]); $this->organization = Organization::create([ 'owner_ref' => $this->owner->public_id, 'name' => 'Bridge Clinic', 'slug' => 'bridge-clinic', 'settings' => [ 'onboarded' => true, 'facility_type' => 'clinic', 'plan' => 'pro', 'plan_expires_at' => now()->addMonth()->toIso8601String(), 'queue_integration_enabled' => 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, ]); $this->patient = Patient::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_number' => 'P-100', 'first_name' => 'Ada', 'last_name' => 'Lovelace', 'gender' => 'female', 'date_of_birth' => '1990-01-01', ]); } /** * Patient-flow board is doctor-only; reception keeps check-in / walk-in helpers. * * @return array{0: User, 1: Member} */ protected function makeDoctor(string $suffix = 'doc'): array { $user = User::create([ 'public_id' => 'bridge-'.$suffix, 'name' => 'Bridge Doctor '.$suffix, 'email' => $suffix.'@bridge.example.com', ]); $member = Member::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'user_ref' => $user->public_id, 'role' => 'doctor', 'branch_id' => $this->branch->id, ]); return [$user, $member]; } public function test_check_in_issues_consultation_ticket_when_integration_on(): void { Http::fake(); $practitioner = \App\Models\Practitioner::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'name' => 'Dr. Bridge', 'room' => 'Room 1', 'is_active' => true, ]); $appointment = 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, 'type' => Appointment::TYPE_SCHEDULED, 'status' => Appointment::STATUS_SCHEDULED, 'scheduled_at' => now()->addHour(), ]); $result = app(AppointmentService::class)->checkIn($appointment, $this->owner->public_id, $this->owner->public_id); $this->assertNotNull($result->queue_ticket_uuid); $this->assertSame('waiting', $result->queue_ticket_status); $this->assertStringStartsWith('C', (string) $result->queue_ticket_number); $this->assertSame('Room 1', $result->queue_destination); $this->assertSame('Dr. Bridge', $result->queue_staff_display_name); $this->assertDatabaseHas('care_queue_tickets', [ 'uuid' => $result->queue_ticket_uuid, 'ticket_number' => $result->queue_ticket_number, 'status' => CareQueueTicket::STATUS_WAITING, 'care_entity' => 'appointment', 'care_entity_id' => $result->id, ]); Http::assertNothingSent(); } public function test_check_in_skips_ticket_when_integration_off(): void { Http::fake(); $settings = $this->organization->settings; $settings['queue_integration_enabled'] = false; $this->organization->update(['settings' => $settings]); $appointment = 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_SCHEDULED, 'status' => Appointment::STATUS_SCHEDULED, 'scheduled_at' => now()->addHour(), ]); $result = app(AppointmentService::class)->checkIn( $appointment->fresh(['organization', 'patient']), $this->owner->public_id, $this->owner->public_id, ); $this->assertNull($result->queue_ticket_uuid); Http::assertNothingSent(); } public function test_waiting_list_shows_ticket_number_when_integration_on(): void { 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, 'queue_ticket_uuid' => 'dddddddd-dddd-dddd-dddd-dddddddddddd', 'queue_ticket_number' => 'C042', 'queue_ticket_status' => 'waiting', ]); [$doctor] = $this->makeDoctor('wait'); $this->actingAs($doctor) ->get(route('care.queue.index')) ->assertOk() ->assertSee('C042') ->assertSee('Call next') ->assertSee('Serve'); } public function test_call_next_backfills_missing_ticket_then_calls(): void { Http::fake(); $practitioner = \App\Models\Practitioner::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'name' => 'Dr. Backfill', 'room' => 'Room 3', 'is_active' => true, ]); 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, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_WAITING, 'scheduled_at' => now(), 'waiting_at' => now(), 'queue_position' => 1, ]); [$doctor] = $this->makeDoctor('backfill'); $response = $this->actingAs($doctor) ->from(route('care.queue.index')) ->post(route('care.queue.call-next'), [ 'branch_id' => $this->branch->id, 'practitioner_id' => $practitioner->id, ]) ->assertSessionHas('success'); $appointment = Appointment::query()->where('patient_id', $this->patient->id)->first(); $this->assertNotNull($appointment?->queue_ticket_uuid); $this->assertSame('called', $appointment->queue_ticket_status); $response->assertRedirect(route('care.appointments.show', $appointment)); $this->assertDatabaseHas('care_queue_tickets', [ 'uuid' => $appointment->queue_ticket_uuid, 'status' => CareQueueTicket::STATUS_CALLED, ]); Http::assertNothingSent(); } public function test_call_next_empty_queue_flashes_info_not_error(): void { Http::fake(); [$doctor] = $this->makeDoctor('empty'); $this->actingAs($doctor) ->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 at your consultation service point.') ->assertSessionMissing('error'); Http::assertNothingSent(); } public function test_recall_reannounces_called_ticket_from_patient_queue(): void { Http::fake(); $practitioner = \App\Models\Practitioner::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'name' => 'Dr. Recall', 'room' => 'Room 2', 'is_active' => true, ]); $appointment = 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, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_SCHEDULED, 'scheduled_at' => now(), ]); $checkedIn = app(AppointmentService::class)->checkIn( $appointment->fresh(['organization', 'patient', 'practitioner']), $this->owner->public_id, $this->owner->public_id, ); $bridge = app(\App\Services\Care\CareQueueBridge::class); $called = $bridge->callNext( $this->organization->fresh(), CareQueueContexts::CONSULTATION, (int) $this->branch->id, null, $practitioner->id, ); $this->assertNotNull($called['ticket']); $appointment = $checkedIn->fresh(); $this->assertSame('called', $appointment->queue_ticket_status); $doctorUser = User::create([ 'public_id' => 'bridge-recall-doc', 'name' => 'Recall Doctor', 'email' => 'bridge-recall@example.com', ]); Member::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'user_ref' => $doctorUser->public_id, 'role' => 'doctor', 'branch_id' => $this->branch->id, ]); \App\Models\Practitioner::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'member_id' => Member::query()->where('user_ref', $doctorUser->public_id)->value('id'), 'user_ref' => $doctorUser->public_id, 'name' => 'Recall Doctor', 'room' => 'Room 2', 'is_active' => true, ]); $this->actingAs($doctorUser) ->from(route('care.queue.index')) ->post(route('care.queue.recall', $appointment)) ->assertRedirect(route('care.queue.index')) ->assertSessionHas('success'); $this->assertSame('called', $appointment->fresh()->queue_ticket_status); [$viewer] = $this->makeDoctor('recall-view'); $this->actingAs($viewer) ->get(route('care.queue.index', ['branch_id' => $this->branch->id])) ->assertOk() ->assertSee($appointment->queue_ticket_number); Http::assertNothingSent(); } public function test_patient_queue_sync_issues_specialty_tickets(): void { Http::fake(); $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' => false, ]], ], ]; $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', ]); // Board GET must remain read-only; ticket backfill is explicit (Call next / artisan). [$doctor] = $this->makeDoctor('sync'); $this->actingAs($doctor) ->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', [ 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'context' => 'dentistry', ]); $this->assertDatabaseHas('care_queue_tickets', [ 'uuid' => $appointment->queue_ticket_uuid, 'ticket_number' => $appointment->queue_ticket_number, 'status' => CareQueueTicket::STATUS_WAITING, ]); Http::assertNothingSent(); } public function test_call_next_ends_current_session_and_opens_next_patient(): void { Http::fake(); $practitioner = \App\Models\Practitioner::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'name' => 'Dr. Advance', 'room' => 'Room 4', 'is_active' => true, ]); $currentPatient = Patient::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_number' => 'P-CUR', 'first_name' => 'Current', 'last_name' => 'Patient', 'gender' => 'female', 'date_of_birth' => '1991-01-01', ]); $currentVisit = \App\Models\Visit::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $currentPatient->id, 'status' => \App\Models\Visit::STATUS_IN_PROGRESS, 'checked_in_at' => now(), ]); $current = Appointment::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $currentPatient->id, 'practitioner_id' => $practitioner->id, 'visit_id' => $currentVisit->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_IN_CONSULTATION, 'scheduled_at' => now()->subHour(), 'waiting_at' => now()->subHour(), 'checked_in_at' => now()->subHour(), 'started_at' => now()->subMinutes(30), 'queue_ticket_status' => 'serving', ]); \App\Models\Consultation::create([ 'owner_ref' => $this->owner->public_id, 'visit_id' => $currentVisit->id, 'appointment_id' => $current->id, 'practitioner_id' => $practitioner->id, 'patient_id' => $currentPatient->id, 'status' => \App\Models\Consultation::STATUS_DRAFT, 'started_at' => now()->subMinutes(30), ]); $nextVisit = \App\Models\Visit::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $this->patient->id, 'status' => \App\Models\Visit::STATUS_OPEN, 'checked_in_at' => now(), ]); $next = 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, 'visit_id' => $nextVisit->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_WAITING, 'scheduled_at' => now(), 'waiting_at' => now(), 'checked_in_at' => now(), 'queue_position' => 1, ]); app(\App\Services\Care\CareQueueBridge::class)->issueForAppointment( $this->organization->fresh(), $next->fresh(['patient', 'practitioner', 'organization']), ); [$doctor] = $this->makeDoctor('advance'); $response = $this->actingAs($doctor) ->post(route('care.queue.call-next'), [ 'branch_id' => $this->branch->id, 'practitioner_id' => $practitioner->id, 'appointment_id' => $current->id, ]) ->assertSessionHas('success'); $this->assertSame(Appointment::STATUS_COMPLETED, $current->fresh()->status); $this->assertSame(\App\Models\Consultation::STATUS_COMPLETED, $current->consultation->fresh()->status); $next = $next->fresh(); $this->assertSame('called', $next->queue_ticket_status); $response->assertRedirect(route('care.appointments.show', $next)); $this->actingAs($doctor) ->get(route('care.appointments.show', $next)) ->assertOk() ->assertSee('Call again'); Http::assertNothingSent(); } public function test_assigned_doctor_call_next_succeeds_with_waiting_appointments(): void { Http::fake(); // Provision consultation desks for an existing GP first so ensure() early-returns // with a points list that does not yet include the demo doctor we link next. $other = \App\Models\Practitioner::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'name' => 'Dr. Other Desk', 'room' => 'Room 1', 'is_active' => true, ]); app(\App\Services\Care\CareQueueProvisioner::class)->ensure( $this->organization->fresh(), CareQueueContexts::CONSULTATION, $this->branch->id, ); $doctorUser = User::create([ 'public_id' => 'bridge-assigned-doc', 'name' => 'Assigned Doctor', 'email' => 'bridge-assigned@example.com', ]); $doctorMember = Member::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'user_ref' => $doctorUser->public_id, 'role' => 'doctor', 'branch_id' => $this->branch->id, ]); $doctor = \App\Models\Practitioner::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'member_id' => $doctorMember->id, 'user_ref' => $doctorUser->public_id, 'name' => 'Dr. Assigned', 'room' => 'Room 9', 'is_active' => true, ]); 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' => $doctor->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_WAITING, 'scheduled_at' => now(), 'waiting_at' => now(), 'queue_position' => 1, ]); // Board KPI / Assigned to you still lists the waiter. $this->actingAs($doctorUser) ->get(route('care.queue.index', ['branch_id' => $this->branch->id])) ->assertOk() ->assertSee('Assigned to you') ->assertSee('Ada Lovelace'); $response = $this->actingAs($doctorUser) ->from(route('care.queue.index', ['branch_id' => $this->branch->id])) ->post(route('care.queue.call-next'), [ 'branch_id' => $this->branch->id, ]) ->assertSessionMissing('info') ->assertSessionHas('success'); $appointment = Appointment::query()->where('practitioner_id', $doctor->id)->first(); $this->assertNotNull($appointment?->queue_ticket_uuid); $this->assertSame('called', $appointment->queue_ticket_status); $response->assertRedirect(route('care.appointments.show', $appointment)); $this->assertDatabaseHas('care_service_points', [ 'organization_id' => $this->organization->id, 'kind' => 'practitioner', 'ref_id' => $doctor->id, 'is_active' => true, ]); $this->assertNotNull($other->fresh()); Http::assertNothingSent(); } public function test_call_next_reclaims_ticket_from_wrong_desk_and_announces(): void { Http::fake(); $doctor = \App\Models\Practitioner::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'name' => 'Dr. Reclaim', 'room' => 'Room 7', 'is_active' => true, ]); $other = \App\Models\Practitioner::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'name' => 'Dr. Other', 'room' => 'Room 8', 'is_active' => true, ]); app(\App\Services\Care\CareQueueProvisioner::class)->ensure( $this->organization->fresh(), CareQueueContexts::CONSULTATION, $this->branch->id, ); $queue = \App\Models\CareServiceQueue::query() ->where('organization_id', $this->organization->id) ->where('context', CareQueueContexts::CONSULTATION) ->where('branch_id', $this->branch->id) ->firstOrFail(); $doctorPoint = \App\Models\CareServicePoint::query() ->where('service_queue_id', $queue->id) ->where('kind', 'practitioner') ->where('ref_id', $doctor->id) ->firstOrFail(); $otherPoint = \App\Models\CareServicePoint::query() ->where('service_queue_id', $queue->id) ->where('kind', 'practitioner') ->where('ref_id', $other->id) ->firstOrFail(); $ticketUuid = (string) \Illuminate\Support\Str::uuid(); CareQueueTicket::create([ 'uuid' => $ticketUuid, 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'service_queue_id' => $queue->id, 'service_point_id' => $otherPoint->id, 'ticket_number' => 'C099', 'status' => CareQueueTicket::STATUS_WAITING, 'priority' => 'walk_in', 'customer_name' => 'Ada Lovelace', 'care_entity' => 'appointment', 'care_entity_id' => null, ]); $appointment = 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' => $doctor->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_WAITING, 'scheduled_at' => now(), 'waiting_at' => now(), 'queue_position' => 1, 'queue_ticket_uuid' => $ticketUuid, 'queue_ticket_number' => 'C099', 'queue_ticket_status' => 'waiting', ]); CareQueueTicket::where('uuid', $ticketUuid)->update([ 'care_entity_id' => $appointment->id, 'care_entity_uuid' => $appointment->uuid, ]); [$caller] = $this->makeDoctor('reclaim'); $this->actingAs($caller) ->from(route('care.queue.index')) ->post(route('care.queue.call-next'), [ 'branch_id' => $this->branch->id, 'practitioner_id' => $doctor->id, ]) ->assertSessionHas('success') ->assertRedirect(route('care.appointments.show', $appointment)); $appointment = $appointment->fresh(); $this->assertSame('called', $appointment->queue_ticket_status); $this->assertSame($doctorPoint->uuid, $appointment->queue_service_point_uuid); $this->assertDatabaseHas('care_queue_tickets', [ 'uuid' => $ticketUuid, 'status' => CareQueueTicket::STATUS_CALLED, 'service_point_id' => $doctorPoint->id, ]); $this->assertDatabaseHas('care_voice_announcements', [ 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'status' => 'pending', ]); $this->actingAs($caller) ->get(route('care.queue.index', [ 'branch_id' => $this->branch->id, 'practitioner_id' => $doctor->id, ])) ->assertOk() ->assertSee('Called') ->assertSee('C099'); Http::assertNothingSent(); } }