diff --git a/modules/vaos/app/models/vaos/v2/unified/base_provider.rb b/modules/vaos/app/models/vaos/v2/unified/base_provider.rb index 5a97c4fbbd79..9afceafa00b9 100644 --- a/modules/vaos/app/models/vaos/v2/unified/base_provider.rb +++ b/modules/vaos/app/models/vaos/v2/unified/base_provider.rb @@ -4,8 +4,8 @@ module VAOS module V2 module Unified class BaseProvider - attr_accessor :id, :name, :address, :phone, :latitude, :longitude, - :provider_type, :schedulable_services, :distance_from_user + attr_accessor :id, :name, :facility_name, :address, :phone, :latitude, :longitude, + :provider_type, :distance_from_user def initialize(attrs = {}) attrs.each { |key, value| send(:"#{key}=", value) if respond_to?(:"#{key}=") } diff --git a/modules/vaos/app/models/vaos/v2/unified/eps_provider.rb b/modules/vaos/app/models/vaos/v2/unified/eps_provider.rb index 436b3c01dfc5..18143e0fca06 100644 --- a/modules/vaos/app/models/vaos/v2/unified/eps_provider.rb +++ b/modules/vaos/app/models/vaos/v2/unified/eps_provider.rb @@ -5,22 +5,53 @@ module V2 module Unified class EpsProvider < BaseProvider attr_accessor :provider_service_id, :network_id, :npi, :specialties, - :digital_booking_features + :digital_booking_features, :appointment_types def initialize(attrs = {}) super self.provider_type = 'community_care' end + ## + # First self-schedulable EPS appointment type id + # + # @return [String] + # @raise [Common::Exceptions::BackendServiceException] when types are missing or none self-schedulable + # + def first_self_schedulable_appointment_type_id! + if appointment_types.blank? + raise Common::Exceptions::BackendServiceException.new( + 'PROVIDER_APPOINTMENT_TYPES_MISSING', + {}, + 502, + 'Provider appointment types data is not available' + ) + end + + self_schedulable = appointment_types.select { |apt| apt[:is_self_schedulable] == true } + if self_schedulable.blank? + raise Common::Exceptions::BackendServiceException.new( + 'PROVIDER_SELF_SCHEDULABLE_TYPES_MISSING', + {}, + 502, + 'No self-schedulable appointment types available for this provider' + ) + end + + self_schedulable.first[:id] + end + # Builds an EpsProvider from an EPS provider service response (Hash or OpenStruct) def self.from_eps_provider_service(provider) provider = provider.to_h if provider.is_a?(OpenStruct) location = provider[:location] || {} + practice_name = location[:name].presence || provider[:name] new( id: provider[:id], provider_service_id: provider[:id], name: provider[:name], + facility_name: practice_name, address: parse_eps_address(location[:address]), phone: extract_phone(provider), latitude: location[:latitude], @@ -29,7 +60,7 @@ def self.from_eps_provider_service(provider) specialties: provider[:specialties] || [], network_id: provider[:network_ids]&.first, digital_booking_features: provider[:features], - schedulable_services: extract_specialties(provider[:specialties]) + appointment_types: provider[:appointment_types] || [] ) end @@ -62,12 +93,6 @@ def self.extract_npi(provider) providers.first&.dig(:npi) end - - def self.extract_specialties(specialties) - return [] if specialties.blank? - - specialties.map { |s| s[:name] }.compact - end end end end diff --git a/modules/vaos/app/models/vaos/v2/unified/va_provider.rb b/modules/vaos/app/models/vaos/v2/unified/va_provider.rb index 4bb31dbda9cc..c60a3104fd51 100644 --- a/modules/vaos/app/models/vaos/v2/unified/va_provider.rb +++ b/modules/vaos/app/models/vaos/v2/unified/va_provider.rb @@ -3,27 +3,34 @@ module VAOS module V2 module Unified + # Represents a single VA schedulable clinic (clinic IEN) at a Lighthouse facility. + # +id+ is the clinic IEN. +location_id+ is the parent facility +unique_id+ used with VAOS + # clinics and slots APIs. class VAProvider < BaseProvider - attr_accessor :location_id, :facility_type, :scheduling_config + attr_accessor :location_id, :facility_type def initialize(attrs = {}) super self.provider_type = 'va' end - # Builds a VAProvider from a Lighthouse Facility object - # (FacilitiesApi::V2::Lighthouse::Facility) - def self.from_lighthouse_facility(facility) + ## + # @param facility [FacilitiesApi::V2::Lighthouse::Facility] parent facility (address, geo, phone) + # @param clinic [OpenStruct, Hash] VAOS clinic payload from SystemsService#get_facility_clinics + # + def self.from_facility_and_clinic(facility, clinic) + clinic = clinic.to_h if clinic.is_a?(OpenStruct) + new( - id: facility.unique_id, + id: clinic[:id], location_id: facility.unique_id, - name: facility.name, + name: clinic[:service_name], + facility_name: facility.name, address: parse_lighthouse_address(facility.address), phone: facility.phone&.dig('main'), latitude: facility.lat, longitude: facility.long, - facility_type: facility.facility_type, - schedulable_services: parse_lighthouse_services(facility.services) + facility_type: facility.facility_type ) end @@ -42,15 +49,6 @@ def self.parse_lighthouse_address(address_hash) zip: physical['zip'] } end - - def self.parse_lighthouse_services(services_hash) - return [] if services_hash.blank? - - health = services_hash['health'] || services_hash[:health] - return [] if health.blank? - - health.filter_map { |svc| svc['serviceId'] || svc['service_id'] || svc[:service_id] } - end end end end diff --git a/modules/vaos/app/serializers/vaos/v2/unified_provider_serializer.rb b/modules/vaos/app/serializers/vaos/v2/unified_provider_serializer.rb index 80e4e6baa898..8d7ea93ffde2 100644 --- a/modules/vaos/app/serializers/vaos/v2/unified_provider_serializer.rb +++ b/modules/vaos/app/serializers/vaos/v2/unified_provider_serializer.rb @@ -10,6 +10,7 @@ def serialize(providers, referral_npi: nil) type: 'unified_provider', attributes: { name: provider.name, + facilityName: provider.facility_name, providerType: provider.provider_type, isReferralProvider: referral_provider?(provider, referral_npi), address: serialize_address(provider.address), @@ -17,7 +18,6 @@ def serialize(providers, referral_npi: nil) latitude: provider.latitude, longitude: provider.longitude, distanceInMiles: provider.distance_from_user&.round(1), - schedulableServices: provider.schedulable_services || [], sortOrder: index } } diff --git a/modules/vaos/app/services/vaos/v2/unified/eligibility_service.rb b/modules/vaos/app/services/vaos/v2/unified/eligibility_service.rb index e2d9ee8f09ee..e54e92a66bf2 100644 --- a/modules/vaos/app/services/vaos/v2/unified/eligibility_service.rb +++ b/modules/vaos/app/services/vaos/v2/unified/eligibility_service.rb @@ -13,17 +13,15 @@ def initialize(current_user) end ## - # Checks patient eligibility for a specific service type at a VA facility - # selected from the unified provider list. Maps the referral's category of care - # from Lighthouse format to VAOS format, then checks whether the patient is - # eligible for direct scheduling. + # Checks patient eligibility for a specific service type at a VA facility. + # Maps the referral's category of care from Lighthouse format to VAOS format, + # then checks whether the patient is eligible for direct scheduling. # - # @param va_provider [VAOS::V2::Unified::VAProvider] selected VA facility from provider search + # @param facility_id [String] VA facility identifier (Lighthouse +unique_id+, same as VAOS location) # @param category_of_care [String] Lighthouse service type from the referral (e.g., 'primaryCare') # @return [Hash] with :facility_id, :vaos_service_type, and :direct_eligible # - def check_eligibility(va_provider, category_of_care) - facility_id = va_provider.location_id + def check_eligibility(facility_id:, category_of_care:) vaos_service_type = ServiceTypeMapper.to_vaos(category_of_care) if vaos_service_type.nil? diff --git a/modules/vaos/app/services/vaos/v2/unified/provider_search_service.rb b/modules/vaos/app/services/vaos/v2/unified/provider_search_service.rb index 09805e21d600..ca7f5e2d34f2 100644 --- a/modules/vaos/app/services/vaos/v2/unified/provider_search_service.rb +++ b/modules/vaos/app/services/vaos/v2/unified/provider_search_service.rb @@ -16,9 +16,9 @@ def initialize(current_user) end ## - # Searches for both VA facilities and EPS CC providers near the user's address, - # filtered by the referral's category of care. Pins the referral's matched CC - # provider at the top, then sorts remaining results by distance. + # Searches for VA clinics (via Lighthouse facilities + VAOS clinics) and EPS CC providers + # near the user's address, filtered by the referral's category of care. Pins the referral's + # matched CC provider at the top, then sorts remaining results by distance. # # @param referral [Object] A CCRA referral object with category_of_care, provider NPI, etc. # @param radius [Integer] Search radius in miles (default: 25) @@ -69,9 +69,7 @@ def fetch_va_providers(user_address, referral, radius, lh_client:) per_page: 50 ) - providers = facilities.map { |f| VAOS::V2::Unified::VAProvider.from_lighthouse_facility(f) } - providers.each { |p| assign_distance(p, user_address) } - filter_va_by_category_of_care(providers, referral.category_of_care) + fetch_providers_for_facilities(facilities, referral, user_address) rescue => e Rails.logger.error("#{log_prefix}: VA facility search failed", { @@ -84,6 +82,19 @@ def fetch_va_providers(user_address, referral, radius, lh_client:) [] end + def fetch_providers_for_facilities(facilities, referral, user_address) + matching_facilities = filter_supported_facilities(facilities, referral.category_of_care) + clinical_service = ServiceTypeMapper.to_vaos(referral.category_of_care) + + matching_facilities.flat_map do |facility| + fetch_clinics_for_facility(facility, clinical_service).map do |clinic| + provider = VAProvider.from_facility_and_clinic(facility, clinic) + assign_distance(provider, user_address) + provider + end + end + end + def fetch_eps_providers(user_address, referral, radius, eps_client:) providers = eps_client.search_by_location( latitude: user_address.latitude, @@ -127,18 +138,45 @@ def assign_distance(provider, user_address) ) end - def filter_va_by_category_of_care(providers, category_of_care) - return providers if category_of_care.blank? + def filter_supported_facilities(facilities, category_of_care) + return facilities if category_of_care.blank? - normalized = category_of_care.to_s.downcase.gsub(/[\s_-]+/, '') + vaos_service_type = ServiceTypeMapper.to_vaos(category_of_care) + return facilities if vaos_service_type.nil? - providers.select do |provider| - provider.schedulable_services.any? do |svc| - svc.to_s.downcase.gsub(/[\s_-]+/, '') == normalized - end + facilities.select do |facility| + eligibility_service.check_eligibility( + facility_id: facility.unique_id, + category_of_care: + )[:direct_eligible] end end + def fetch_clinics_for_facility(facility, clinical_service) + systems_service.get_facility_clinics( + location_id: facility.unique_id, + clinical_service: + ) + rescue => e + Rails.logger.warn( + "#{log_prefix}: Clinic fetch failed for facility #{facility.unique_id}", + { + error_class: e.class.name, + clinical_service:, + user_uuid: @cached_user_uuid + }.compact + ) + [] + end + + def systems_service + @systems_service ||= VAOS::V2::SystemsService.new(current_user) + end + + def eligibility_service + @eligibility_service ||= EligibilityService.new(current_user) + end + def combine_and_sort(va_providers, eps_providers, referral) referral_provider, other_eps = partition_referral_provider(eps_providers, referral) diff --git a/modules/vaos/app/services/vaos/v2/unified/slots_service.rb b/modules/vaos/app/services/vaos/v2/unified/slots_service.rb new file mode 100644 index 000000000000..c31a00877739 --- /dev/null +++ b/modules/vaos/app/services/vaos/v2/unified/slots_service.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module VAOS + module V2 + module Unified + # Fetches appointment availability (slots) for unified scheduling: VA clinic providers via + # VAOS SystemsService, community care via EPS ProviderService. VA providers must include + # +location_id+ and +id+ (clinic IEN; see VAProvider). + class SlotsService + def initialize(user) + @user = user + end + + ## + # @param provider [BaseProvider] VAProvider or EpsProvider + # @param start_dt [String] ISO8601 start of search window (VA and EPS) + # @param end_dt [String] ISO8601 end of search window (VA and EPS) + # @param clinical_service [String, nil] VAOS clinical service (required for VA) + # @param appointment_id [String, nil] EPS draft appointment id (required for EPS) + # @return [Array] + # + # EPS +appointmentTypeId+ comes from +EpsProvider#first_self_schedulable_appointment_type_id!+ + # (EPS +appointment_types+ on the provider). + # + def slots_for(provider:, start_dt:, end_dt:, clinical_service: nil, appointment_id: nil) + case provider + when VAProvider + fetch_va_slots(provider, start_dt, end_dt, clinical_service) + when EpsProvider + fetch_eps_slots(provider, start_dt, end_dt, appointment_id) + else + raise ArgumentError, "Unsupported provider type: #{provider.class.name}" + end + end + + private + + attr_reader :user + + def fetch_va_slots(provider, start_dt, end_dt, clinical_service) + raise Common::Exceptions::ParameterMissing, 'clinical_service' if clinical_service.blank? + + if provider.location_id.blank? || provider.id.blank? + raise Common::Exceptions::UnprocessableEntity.new( + detail: 'VA provider requires location_id and id (clinic IEN) for slot lookup' + ) + end + + raw = systems_service.get_available_slots( + location_id: provider.location_id, + clinic_id: provider.id, + clinical_service:, + provider_id: nil, + start_dt:, + end_dt: + ) + Array(raw).map { |slot| VASlot.from_vaos_slot(slot, location_id: provider.location_id) } + end + + def fetch_eps_slots(provider, start_dt, end_dt, appointment_id) + raise Common::Exceptions::ParameterMissing, 'start_dt' if start_dt.blank? + raise Common::Exceptions::ParameterMissing, 'end_dt' if end_dt.blank? + raise Common::Exceptions::ParameterMissing, 'appointment_id' if appointment_id.blank? + + appointment_type_id = provider.first_self_schedulable_appointment_type_id! + provider_id = provider.provider_service_id.presence || provider.id + opts = { + appointmentTypeId: appointment_type_id, + startOnOrAfter: start_dt, + startBefore: end_dt, + appointmentId: appointment_id + } + response = eps_provider_service.get_provider_slots(provider_id, opts) + slots = response&.slots + Array(slots).map { |slot| EpsSlot.from_eps_slot(slot) } + end + + def systems_service + @systems_service ||= VAOS::V2::SystemsService.new(user) + end + + def eps_provider_service + @eps_provider_service ||= Eps::ProviderService.new(user) + end + end + end + end +end diff --git a/modules/vaos/spec/controllers/v2/providers_controller_index_spec.rb b/modules/vaos/spec/controllers/v2/providers_controller_index_spec.rb index 92f708297c73..da019b270355 100644 --- a/modules/vaos/spec/controllers/v2/providers_controller_index_spec.rb +++ b/modules/vaos/spec/controllers/v2/providers_controller_index_spec.rb @@ -18,15 +18,15 @@ let(:va_provider) do VAOS::V2::Unified::VAProvider.new( - id: '983', + id: '455', location_id: '983', - name: 'Cheyenne VA Medical Center', + facility_name: 'Cheyenne VA Medical Center', + name: 'CHY UROLOGY', address: { street1: '2360 E Pershing Blvd', city: 'Cheyenne', state: 'WY', zip: '82001' }, phone: '307-778-7550', latitude: 41.1456, longitude: -104.7892, - distance_from_user: 3.2, - schedulable_services: %w[primaryCare urology] + distance_from_user: 3.2 ) end @@ -40,8 +40,7 @@ latitude: 28.08061, longitude: -80.60322, npi: '91560381x', - distance_from_user: 2.1, - schedulable_services: ['Urology'] + distance_from_user: 2.1 ) end diff --git a/modules/vaos/spec/models/vaos/v2/unified/eps_provider_spec.rb b/modules/vaos/spec/models/vaos/v2/unified/eps_provider_spec.rb index 5f30133d61ec..464da61d13b7 100644 --- a/modules/vaos/spec/models/vaos/v2/unified/eps_provider_spec.rb +++ b/modules/vaos/spec/models/vaos/v2/unified/eps_provider_spec.rb @@ -36,7 +36,10 @@ features: { is_digital: true, direct_booking: { is_enabled: true } - } + }, + appointment_types: [ + { id: 'ov', is_self_schedulable: true } + ] } end @@ -53,7 +56,8 @@ expect(provider.npi).to eq('91560381x') expect(provider.network_id).to eq('sandboxnetwork-5vuTac8v') expect(provider.specialties).to eq([{ id: '208800000X', name: 'Urology' }]) - expect(provider.schedulable_services).to eq(['Urology']) + expect(provider.facility_name).to eq('FHA South Melbourne Medical Complex') + expect(provider.appointment_types).to eq([{ id: 'ov', is_self_schedulable: true }]) expect(provider.address).to eq({ street1: '1105 Palmetto Ave', city: 'Melbourne', @@ -82,5 +86,48 @@ expect(provider.npi).to be_nil end + + it 'uses provider name as facility_name when location has no name' do + eps_provider[:location] = eps_provider[:location].except(:name) + provider = described_class.from_eps_provider_service(eps_provider) + + expect(provider.facility_name).to eq('Dr. Bones @ FHA South Melbourne Medical Complex') + end + + it 'defaults appointment_types to empty when omitted' do + eps_provider.delete(:appointment_types) + provider = described_class.from_eps_provider_service(eps_provider) + + expect(provider.appointment_types).to eq([]) + end + end + + describe '#first_self_schedulable_appointment_type_id!' do + it 'returns the first self-schedulable type id' do + provider = described_class.new( + appointment_types: [ + { id: 'phone', is_self_schedulable: false }, + { id: 'ov', is_self_schedulable: true } + ] + ) + + expect(provider.first_self_schedulable_appointment_type_id!).to eq('ov') + end + + it 'raises when appointment_types is blank' do + provider = described_class.new(appointment_types: []) + + expect { provider.first_self_schedulable_appointment_type_id! } + .to raise_error(Common::Exceptions::BackendServiceException) + end + + it 'raises when no self-schedulable types exist' do + provider = described_class.new( + appointment_types: [{ id: 'phone', is_self_schedulable: false }] + ) + + expect { provider.first_self_schedulable_appointment_type_id! } + .to raise_error(Common::Exceptions::BackendServiceException) + end end end diff --git a/modules/vaos/spec/models/vaos/v2/unified/va_provider_spec.rb b/modules/vaos/spec/models/vaos/v2/unified/va_provider_spec.rb index 4fcd5586f1aa..36f0ba512342 100644 --- a/modules/vaos/spec/models/vaos/v2/unified/va_provider_spec.rb +++ b/modules/vaos/spec/models/vaos/v2/unified/va_provider_spec.rb @@ -10,18 +10,15 @@ end end - describe '.from_lighthouse_facility' do + describe '.from_facility_and_clinic' do let(:facility) do double( 'FacilitiesApi::V2::Lighthouse::Facility', - id: 'vha_983', unique_id: '983', name: 'Cheyenne VA Medical Center', address: { 'physical' => { 'address1' => '2360 East Pershing Boulevard', - 'address2' => 'Suite 100', - 'address3' => 'Building A', 'city' => 'Cheyenne', 'state' => 'WY', 'zip' => '82001' @@ -40,41 +37,59 @@ ) end - it 'maps Lighthouse facility fields to VaProvider' do - provider = described_class.from_lighthouse_facility(facility) + let(:clinic) do + { + id: '1014', + station_id: '983', + service_name: 'CHY AUDIOLOGY', + physical_location: 'Main building' + } + end + + it 'sets id and name from the clinic payload and location_id from the facility' do + provider = described_class.from_facility_and_clinic(facility, clinic) - expect(provider.id).to eq('983') + expect(provider.id).to eq('1014') expect(provider.location_id).to eq('983') - expect(provider.name).to eq('Cheyenne VA Medical Center') + expect(provider.facility_name).to eq('Cheyenne VA Medical Center') + expect(provider.name).to eq('CHY AUDIOLOGY') expect(provider.provider_type).to eq('va') - expect(provider.latitude).to eq(41.1456) - expect(provider.longitude).to eq(-104.7892) - expect(provider.phone).to eq('307-778-7550') - expect(provider.distance_from_user).to be_nil - expect(provider.facility_type).to eq('va_health_facility') - expect(provider.schedulable_services).to eq(%w[primaryCare audiology]) - expect(provider.address).to eq({ - street1: '2360 East Pershing Boulevard', - street2: 'Suite 100', - street3: 'Building A', - city: 'Cheyenne', - state: 'WY', - zip: '82001' - }) end - it 'handles nil services gracefully' do - allow(facility).to receive(:services).and_return(nil) - provider = described_class.from_lighthouse_facility(facility) + it 'uses service_name for name' do + provider = described_class.from_facility_and_clinic( + facility, + clinic.merge(service_name: 'PODIATRY CLINIC') + ) - expect(provider.schedulable_services).to eq([]) + expect(provider.name).to eq('PODIATRY CLINIC') end - it 'handles nil address gracefully' do - allow(facility).to receive(:address).and_return(nil) - provider = described_class.from_lighthouse_facility(facility) + it 'uses facility unique_id as location_id regardless of clinic station_id' do + satellite_clinic = clinic.merge(station_id: '983GC', id: '945') + + provider = described_class.from_facility_and_clinic(facility, satellite_clinic) + + expect(provider.location_id).to eq('983') + expect(provider.id).to eq('945') + end + + it 'accepts OpenStruct clinic payloads' do + provider = described_class.from_facility_and_clinic( + facility, + OpenStruct.new(clinic) + ) + + expect(provider.id).to eq('1014') + expect(provider.name).to eq('CHY AUDIOLOGY') + end + + it 'handles nil facility services gracefully' do + allow(facility).to receive(:services).and_return(nil) + + provider = described_class.from_facility_and_clinic(facility, clinic) - expect(provider.address).to be_nil + expect(provider.facility_name).to eq('Cheyenne VA Medical Center') end end end diff --git a/modules/vaos/spec/serializers/vaos/v2/unified_provider_serializer_spec.rb b/modules/vaos/spec/serializers/vaos/v2/unified_provider_serializer_spec.rb index 7e1018c25e69..a2b2b4f2f1cf 100644 --- a/modules/vaos/spec/serializers/vaos/v2/unified_provider_serializer_spec.rb +++ b/modules/vaos/spec/serializers/vaos/v2/unified_provider_serializer_spec.rb @@ -7,14 +7,15 @@ let(:va_provider) do VAOS::V2::Unified::VAProvider.new( - id: '983', - name: 'Cheyenne VA Medical Center', + id: '1081', + location_id: '983', + name: 'CHY AUDIOLOGY', + facility_name: 'Cheyenne VA Medical Center', address: { street1: '2360 E Pershing Blvd', city: 'Cheyenne', state: 'WY', zip: '82001' }, phone: '307-778-7550', latitude: 41.1456, longitude: -104.7892, - distance_from_user: 3.24, - schedulable_services: %w[primaryCare urology] + distance_from_user: 3.24 ) end @@ -22,13 +23,13 @@ VAOS::V2::Unified::EpsProvider.new( id: '9mN718pH', name: 'Dr. Bones @ Melbourne Medical', + facility_name: 'Melbourne Medical', address: { street1: '1105 Palmetto Ave', city: 'Melbourne', state: 'FL', zip: '32901' }, phone: '555-555-0001', latitude: 28.08061, longitude: -80.60322, npi: '91560381x', - distance_from_user: 2.1, - schedulable_services: ['Urology'] + distance_from_user: 2.1 ) end @@ -44,11 +45,11 @@ it 'serializes VA provider attributes' do result = serializer.serialize([va_provider]).first - expect(result[:id]).to eq('983') - expect(result[:attributes][:name]).to eq('Cheyenne VA Medical Center') + expect(result[:id]).to eq('1081') + expect(result[:attributes][:name]).to eq('CHY AUDIOLOGY') + expect(result[:attributes][:facilityName]).to eq('Cheyenne VA Medical Center') expect(result[:attributes][:providerType]).to eq('va') expect(result[:attributes][:distanceInMiles]).to eq(3.2) - expect(result[:attributes][:schedulableServices]).to eq(%w[primaryCare urology]) end it 'serializes EPS provider attributes' do @@ -56,7 +57,10 @@ expect(result[:id]).to eq('9mN718pH') expect(result[:attributes][:name]).to eq('Dr. Bones @ Melbourne Medical') + expect(result[:attributes][:facilityName]).to eq('Melbourne Medical') expect(result[:attributes][:providerType]).to eq('community_care') + expect(result[:attributes]).not_to have_key(:locationId) + expect(result[:attributes]).not_to have_key(:clinicId) end it 'marks the referral provider correctly' do diff --git a/modules/vaos/spec/services/vaos/v2/unified/eligibility_service_spec.rb b/modules/vaos/spec/services/vaos/v2/unified/eligibility_service_spec.rb index af181d34e0a6..3d5756629a02 100644 --- a/modules/vaos/spec/services/vaos/v2/unified/eligibility_service_spec.rb +++ b/modules/vaos/spec/services/vaos/v2/unified/eligibility_service_spec.rb @@ -12,20 +12,12 @@ allow(VAOS::V2::PatientsService).to receive(:new).and_return(patients_service) end - def build_va_provider(location_id:) - VAOS::V2::Unified::VAProvider.new( - id: location_id, - location_id:, - name: "Test Facility #{location_id}" - ) - end - def eligibility_result(eligible:) OpenStruct.new(eligible:) end describe '#check_eligibility' do - let(:va_provider) { build_va_provider(location_id: '983') } + let(:facility_id) { '983' } context 'when the patient is eligible for direct scheduling' do before do @@ -34,7 +26,7 @@ def eligibility_result(eligible:) end it 'returns eligible with the mapped VAOS service type' do - result = service.check_eligibility(va_provider, 'primaryCare') + result = service.check_eligibility(facility_id:, category_of_care: 'primaryCare') expect(result[:facility_id]).to eq('983') expect(result[:vaos_service_type]).to eq('primaryCare') @@ -42,7 +34,7 @@ def eligibility_result(eligible:) end it 'checks direct eligibility' do - service.check_eligibility(va_provider, 'primaryCare') + service.check_eligibility(facility_id:, category_of_care: 'primaryCare') expect(patients_service).to have_received(:get_patient_appointment_metadata) .with('primaryCare', '983', 'direct') @@ -56,7 +48,7 @@ def eligibility_result(eligible:) end it 'returns direct as ineligible' do - result = service.check_eligibility(va_provider, 'primaryCare') + result = service.check_eligibility(facility_id:, category_of_care: 'primaryCare') expect(result[:direct_eligible]).to be false end @@ -70,7 +62,7 @@ def eligibility_result(eligible:) end it 'marks direct as ineligible' do - result = service.check_eligibility(va_provider, 'primaryCare') + result = service.check_eligibility(facility_id:, category_of_care: 'primaryCare') expect(result[:direct_eligible]).to be false end @@ -78,7 +70,7 @@ def eligibility_result(eligible:) context 'when the category of care is unmappable' do it 'returns nil for vaos_service_type and false for direct_eligible' do - result = service.check_eligibility(va_provider, 'unknownServiceType') + result = service.check_eligibility(facility_id:, category_of_care: 'unknownServiceType') expect(result[:facility_id]).to eq('983') expect(result[:vaos_service_type]).to be_nil diff --git a/modules/vaos/spec/services/vaos/v2/unified/provider_search_service_spec.rb b/modules/vaos/spec/services/vaos/v2/unified/provider_search_service_spec.rb index 02d7c95c1f38..ffbad02b053f 100644 --- a/modules/vaos/spec/services/vaos/v2/unified/provider_search_service_spec.rb +++ b/modules/vaos/spec/services/vaos/v2/unified/provider_search_service_spec.rb @@ -52,6 +52,18 @@ } end + let(:urology_clinic) do + OpenStruct.new( + id: '455', + station_id: '983', + service_name: 'CHY UROLOGY', + physical_location: nil + ) + end + + let(:systems_service) { instance_double(VAOS::V2::SystemsService) } + let(:eligibility_service) { instance_double(VAOS::V2::Unified::EligibilityService) } + before do allow(user).to receive(:vet360_contact_info).and_return(vet360_contact_info) end @@ -63,17 +75,24 @@ before do allow(FacilitiesApi::V2::Lighthouse::Client).to receive(:new).and_return(lighthouse_client) allow(Eps::ProviderService).to receive(:new).and_return(eps_provider_service) + allow(VAOS::V2::SystemsService).to receive(:new).with(user).and_return(systems_service) + allow(VAOS::V2::Unified::EligibilityService).to receive(:new).with(user).and_return(eligibility_service) + allow(eligibility_service).to receive(:check_eligibility).and_return({ direct_eligible: true }) allow(lighthouse_client).to receive(:get_facilities).and_return([lighthouse_facility]) allow(eps_provider_service).to receive(:search_by_location).and_return([eps_provider_hash]) + allow(systems_service).to receive(:get_facility_clinics).and_return([urology_clinic]) end - it 'returns a combined list of VA and EPS providers' do + it 'returns a combined list of VA clinics and EPS providers' do results = service.search(referral:) expect(results.size).to eq(2) provider_types = results.map(&:provider_type) expect(provider_types).to include('va', 'community_care') + va = results.find { |p| p.provider_type == 'va' } + expect(va.id).to eq('455') + expect(va.location_id).to eq('983') end it 'pins the referral matched provider at the top' do @@ -132,6 +151,27 @@ ) end + it 'fetches VAOS clinics using ServiceTypeMapper.to_vaos(category_of_care)' do + service.search(referral:) + + # UROLOGY is not in LIGHTHOUSE_TO_VAOS; mapper returns nil + expect(systems_service).to have_received(:get_facility_clinics).with( + location_id: '983', + clinical_service: nil + ) + end + + it 'passes mapped clinical service when category_of_care maps to VAOS' do + audio_referral = double('Referral', category_of_care: 'audiology', provider_npi: '91560381x') + + service.search(referral: audio_referral) + + expect(systems_service).to have_received(:get_facility_clinics).with( + location_id: '983', + clinical_service: 'audiology' + ) + end + it 'raises error when user has no residential address' do allow(vet360_contact_info).to receive(:residential_address).and_return(nil) @@ -162,7 +202,7 @@ expect(results.first.provider_type).to eq('va') end - it 'filters VA facilities by category of care' do + it 'includes all facilities when category_of_care does not map to a VAOS service (no eligibility filter)' do non_matching_facility = double( 'Facility', id: 'vha_984', unique_id: '984', name: 'Other VA', @@ -177,8 +217,47 @@ results = service.search(referral:) va_providers = results.select { |p| p.provider_type == 'va' } - expect(va_providers.size).to eq(1) - expect(va_providers.first.id).to eq('983') + expect(va_providers.size).to eq(2) + expect(va_providers.map(&:location_id).sort).to eq(%w[983 984]) + expect(systems_service).to have_received(:get_facility_clinics).twice + expect(eligibility_service).not_to have_received(:check_eligibility) + end + + it 'excludes VA facilities that fail direct-scheduling eligibility when category maps to VAOS' do + non_matching_facility = double( + 'Facility', + id: 'vha_984', unique_id: '984', name: 'Other VA', + address: nil, phone: nil, lat: 28.12, long: -80.65, + facility_type: 'va_health_facility', + services: { 'health' => [{ 'serviceId' => 'audiology' }] } + ) + allow(lighthouse_client).to receive(:get_facilities).and_return( + [lighthouse_facility, non_matching_facility] + ) + audio_referral = double('Referral', category_of_care: 'audiology', provider_npi: '91560381x') + + allow(eligibility_service).to receive(:check_eligibility) do |facility_id:, category_of_care:| + expect(category_of_care).to eq('audiology') + { + facility_id:, + vaos_service_type: 'audiology', + direct_eligible: facility_id == '983' + } + end + + results = service.search(referral: audio_referral) + + va_providers = results.select { |p| p.provider_type == 'va' } + expect(va_providers.map(&:location_id)).to eq(['983']) + expect(systems_service).to have_received(:get_facility_clinics).once + end + + it 'returns no VA providers when get_facility_clinics returns no clinics' do + allow(systems_service).to receive(:get_facility_clinics).and_return([]) + + results = service.search(referral:) + + expect(results.map(&:provider_type)).to eq(['community_care']) end it 'uses the default 25-mile radius' do diff --git a/modules/vaos/spec/services/vaos/v2/unified/slots_service_spec.rb b/modules/vaos/spec/services/vaos/v2/unified/slots_service_spec.rb new file mode 100644 index 000000000000..d2ac2a705587 --- /dev/null +++ b/modules/vaos/spec/services/vaos/v2/unified/slots_service_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe VAOS::V2::Unified::SlotsService do + let(:user) { build(:user, :vaos) } + let(:service) { described_class.new(user) } + + describe '#slots_for' do + context 'with an unsupported provider' do + let(:provider) { VAOS::V2::Unified::BaseProvider.new(id: 'x') } + + it 'raises ArgumentError' do + expect do + service.slots_for(provider:, start_dt: '2025-01-01T00:00:00Z', end_dt: '2025-01-02T00:00:00Z') + end.to raise_error(ArgumentError, /Unsupported provider type/) + end + end + + context 'with VAProvider' do + let(:provider) do + VAOS::V2::Unified::VAProvider.new( + id: '1081', + location_id: '983' + ) + end + + let(:systems_service) { instance_double(VAOS::V2::SystemsService) } + let(:raw_slot) do + OpenStruct.new( + id: 'slot-1', + start: '2025-01-01T10:00:00Z', + end: '2025-01-01T10:30:00Z', + clinic: { clinic_ien: '1081' }, + location: { vha_facility_id: '983' } + ) + end + + before do + allow(VAOS::V2::SystemsService).to receive(:new).with(user).and_return(systems_service) + end + + it 'returns normalized VASlot records' do + allow(systems_service).to receive(:get_available_slots).and_return([raw_slot]) + + slots = service.slots_for( + provider:, + start_dt: '2025-01-01T00:00:00Z', + end_dt: '2025-01-02T00:00:00Z', + clinical_service: 'audiology' + ) + + expect(slots.size).to eq(1) + expect(slots.first).to be_a(VAOS::V2::Unified::VASlot) + expect(slots.first.id).to eq('slot-1') + expect(slots.first.location_id).to eq('983') + end + + it 'raises when clinical_service is blank' do + expect do + service.slots_for( + provider:, + start_dt: '2025-01-01T00:00:00Z', + end_dt: '2025-01-02T00:00:00Z', + clinical_service: nil + ) + end.to raise_error(Common::Exceptions::ParameterMissing) + end + + it 'raises when id (clinic IEN) is blank' do + provider.id = nil + + expect do + service.slots_for( + provider:, + start_dt: '2025-01-01T00:00:00Z', + end_dt: '2025-01-02T00:00:00Z', + clinical_service: 'audiology' + ) + end.to raise_error(Common::Exceptions::UnprocessableEntity) + end + + it 'returns an empty array when upstream returns no slots' do + allow(systems_service).to receive(:get_available_slots).and_return([]) + + slots = service.slots_for( + provider:, + start_dt: '2025-01-01T00:00:00Z', + end_dt: '2025-01-02T00:00:00Z', + clinical_service: 'audiology' + ) + + expect(slots).to eq([]) + end + end + + context 'with EpsProvider' do + let(:appointment_types) do + [ + { id: 'phone', is_self_schedulable: false }, + { id: 'ov', is_self_schedulable: true } + ] + end + + let(:provider) do + VAOS::V2::Unified::EpsProvider.new( + id: '9mN718pH', + provider_service_id: '9mN718pH', + appointment_types: + ) + end + + let(:eps_service) { instance_double(Eps::ProviderService) } + let(:start_dt) { '2025-01-01T00:00:00Z' } + let(:end_dt) { '2025-01-02T00:00:00Z' } + let(:appointment_id) { 'draft-1' } + + before do + allow(Eps::ProviderService).to receive(:new).with(user).and_return(eps_service) + end + + it 'returns normalized EpsSlot records and passes EPS slot params' do + allow(eps_service).to receive(:get_provider_slots).and_return( + OpenStruct.new(slots: [{ id: 'eps-slot', start: '2025-01-01T12:00:00Z', provider_service_id: '9mN718pH' }]) + ) + + slots = service.slots_for( + provider:, + start_dt:, + end_dt:, + appointment_id: + ) + + expect(slots.size).to eq(1) + expect(slots.first).to be_a(VAOS::V2::Unified::EpsSlot) + expect(slots.first.id).to eq('eps-slot') + expect(eps_service).to have_received(:get_provider_slots).with( + '9mN718pH', + { + appointmentTypeId: 'ov', + startOnOrAfter: start_dt, + startBefore: end_dt, + appointmentId: appointment_id + } + ) + end + + it 'returns an empty array when EPS returns no slots key' do + allow(eps_service).to receive(:get_provider_slots).and_return(OpenStruct.new(slots: nil)) + + slots = service.slots_for( + provider:, + start_dt:, + end_dt:, + appointment_id: + ) + + expect(slots).to eq([]) + end + + it 'raises when appointment_types is blank' do + provider.appointment_types = [] + + expect do + service.slots_for(provider:, start_dt:, end_dt:, appointment_id:) + end.to raise_error(Common::Exceptions::BackendServiceException) + end + + it 'raises when no self-schedulable appointment types exist' do + provider.appointment_types = [{ id: 'phone', is_self_schedulable: false }] + + expect do + service.slots_for(provider:, start_dt:, end_dt:, appointment_id:) + end.to raise_error(Common::Exceptions::BackendServiceException) + end + + it 'raises when appointment_id is blank' do + expect do + service.slots_for( + provider:, + start_dt:, + end_dt:, + appointment_id: nil + ) + end.to raise_error(Common::Exceptions::ParameterMissing) + end + end + end +end