Skip to content

Commit 9b47d9d

Browse files
authored
Enhance facility name fetching for OH Rx (#24437)
* feat: Add prescription tracking support to UHD - Add tracking attribute to UnifiedHealthData::Prescription model as array - Update VistaPrescriptionAdapter to parse trackingInfo from VistA responses - Add date formatting for shippedDate (VistA format to ISO 8601) - Refactor adapter methods to eliminate MethodLength violation - Update serializer to include tracking data in API responses - Set is_trackable dynamically based on tracking data presence - Update all related test files to use tracking array format - Remove tracking_information attribute (no backwards compatibility needed) Tracking objects include: prescriptionName, prescriptionNumber, ndcNumber, prescriptionId, trackingNumber, shippedDate, carrier, otherPrescriptions * feat: Add Oracle Health prescription tracking support - Add tracking information parsing from FHIR MedicationDispense identifiers - Extract tracking number, prescription number, carrier, shipped date from identifiers - Add NDC number extraction from medicationCodeableConcept coding - Set is_trackable dynamically based on tracking data presence - Refactor build_prescription_attributes to eliminate MethodLength violation - Add comprehensive test coverage for Oracle Health tracking scenarios - Handle multiple MedicationDispense resources with tracking data - Gracefully handle missing tracking data with empty arrays Oracle Health tracking objects include: prescriptionName, prescriptionNumber, ndcNumber, prescriptionId, trackingNumber, shippedDate, carrier, otherPrescriptions * Fix NDC extraction to use individual dispense level - Move NDC extraction from top-level MedicationRequest to individual MedicationDispense resources - Update extract_ndc_number method to work with dispense parameter instead of resource - Update corresponding spec tests to reflect new NDC extraction behavior - Ensures FHIR compliance where each dispense can have its own medication coding * Add VistA tracking specs * Split adapter spec into EHR specific files * feat: Enhanced facility name extraction for Oracle Health prescriptions - Updated extract_facility_name to use 3-digit station number extraction from latest dispense - Added 3-tier fallback strategy: Rails cache → Lighthouse API → station number - Created FacilityNameCacheJob for hourly VHA facility name caching with 4-hour TTL - Added comprehensive StatsD metrics for cache performance monitoring - Updated prescription model to include type attribute - Enhanced test coverage for facility name extraction scenarios - Added periodic job scheduling at 37 minutes past hour - Removed legacy fallbacks to encounter and performer for focused approach Improves facility name resolution for Oracle Health prescriptions by using VHA station numbers from dispense locations with intelligent caching strategy. * rubocop * Style updates * Simplify facility name logic * Fix prescription_id type in OracleHealthPrescriptionAdapter to maintain consistency * remove unnecessary compact call * rubocop * Enhance facility name extraction by implementing cache fallback and API retrieval in OracleHealthPrescriptionAdapter * Implement caching for facility name retrieval in fetch_facility_name_from_api method * Fix facility name cache pagination and TTL * Add explicit client load * Normalize refill orders before building UHD payload * Allow UHD facility cache fallback to full facility identifier * rubocop * Add CODEOWNERS entry for app/sidekiq/unified_health_data/facility_name_cache_job.rb * Add CODEOWNERS entry for spec/sidekiq/unified_health_data/facility_name_cache_job_spec.rb * Use Rails.cache.exist? to determine facility-name cache hits Recognize explicit cache entries (including nil) by checking Rails.cache.exist? instead of relying on the truthiness of Rails.cache.read. Update related specs to stub Rails.cache.exist? (and provide a default) so tests reflect the new cache-hit semantics. * UnifiedHealthData: paginate Lighthouse facilities using response.links['next'] Replace manual page increment and batch-size stop logic with following the response.links['next'] URL. Parse next link query params and request the next page from the Lighthouse client. This ensures robust pagination based on API-provided links. Update specs to stub response.links by default and assert subsequent pages are requested using the next link's query params. * Improve var name * UnifiedHealthData: adjust facility lookup logging levels and update specs - Log invalid station extraction as error when station format can't be parsed - Change Lighthouse "no facility" log from info to warn - Escalate API failure logging from warn to error - Update specs to expect the new logging behavior and stub logger calls * Back model change out in favor of separate PR * rubocop * UnifiedHealthData: coerce labs_logging_date_range_days to integer in LabsRefreshJob Call .to_i when reading Settings.mhv.uhd.labs_logging_date_range_days and update the spec to match so date arithmetic always uses an integer. * Fix log level expectation
1 parent 75dabd2 commit 9b47d9d

File tree

11 files changed

+751
-62
lines changed

11 files changed

+751
-62
lines changed

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,7 @@ app/sidekiq/terms_of_use @department-of-veterans-affairs/octo-identity
502502
app/sidekiq/test_user_dashboard @department-of-veterans-affairs/qa-standards @department-of-veterans-affairs/backend-review-group
503503
app/sidekiq/test_user_dashboard/daily_maintenance.rb @department-of-veterans-affairs/qa-standards @department-of-veterans-affairs/backend-review-group
504504
app/sidekiq/transactional_email_analytics_job.rb @department-of-veterans-affairs/vfs-authenticated-experience-backend @department-of-veterans-affairs/backend-review-group
505+
app/sidekiq/unified_health_data/facility_name_cache_job.rb @department-of-veterans-affairs/va-cto-health-products @department-of-veterans-affairs/backend-review-group
505506
app/sidekiq/unified_health_data/labs_refresh_job.rb @department-of-veterans-affairs/va-cto-health-products @department-of-veterans-affairs/backend-review-group
506507
app/sidekiq/user_actions_cleanup_job.rb @department-of-veterans-affairs/octo-identity
507508
app/sidekiq/va_notify_dd_email_job.rb @department-of-veterans-affairs/va-notify-write @department-of-veterans-affairs/backend-review-group
@@ -1893,6 +1894,7 @@ spec/sidekiq/simple_forms_api/form_remediation/upload_retry_job_spec @department
18931894
spec/sidekiq/terms_of_use @department-of-veterans-affairs/octo-identity
18941895
spec/sidekiq/test_user_dashboard @department-of-veterans-affairs/qa-standards @department-of-veterans-affairs/backend-review-group
18951896
spec/sidekiq/transactional_email_analytics_job_spec.rb @department-of-veterans-affairs/vfs-authenticated-experience-backend @department-of-veterans-affairs/backend-review-group
1897+
spec/sidekiq/unified_health_data/facility_name_cache_job_spec.rb @department-of-veterans-affairs/va-cto-health-products @department-of-veterans-affairs/backend-review-group
18961898
spec/sidekiq/unified_health_data/labs_refresh_job_spec.rb @department-of-veterans-affairs/va-cto-health-products @department-of-veterans-affairs/backend-review-group
18971899
spec/sidekiq/user_actions_cleanup_job_spec.rb @department-of-veterans-affairs/octo-identity
18981900
spec/sidekiq/va_notify_dd_email_job_spec.rb @department-of-veterans-affairs/va-notify-write @department-of-veterans-affairs/backend-review-group
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# frozen_string_literal: true
2+
3+
require 'lighthouse/facilities/v1/client'
4+
5+
module UnifiedHealthData
6+
# FacilityNameCacheJob
7+
#
8+
# This Sidekiq job fetches VHA facilities from the Lighthouse Facilities API
9+
# and caches a mapping of station_number -> facility_name in Rails cache.
10+
# The cache is set to expire after 4 hours to ensure fresh data with hourly refreshes.
11+
#
12+
# Why:
13+
# - Prescription processing needs facility names for station numbers from Oracle Health
14+
# - Making individual API calls per prescription is inefficient and can hit rate limits
15+
# - HealthFacility table excludes some facilities that VHA includes
16+
# - Rails cache provides fast lookups with automatic expiration
17+
#
18+
# How:
19+
# - Fetches all VHA facilities from Lighthouse API
20+
# - Extracts station numbers from facility IDs (removes 'vha_' prefix)
21+
# - Stores station_number -> facility_name mapping in Rails cache
22+
# - Sets 4-hour TTL with hourly refresh for reliability
23+
#
24+
class FacilityNameCacheJob
25+
include Sidekiq::Job
26+
27+
CACHE_KEY_PREFIX = 'uhd:facility_names'
28+
BATCH_SIZE = 1000
29+
30+
# retry for ~30 minutes max since job runs every hour
31+
# https://github.com/sidekiq/sidekiq/wiki/Error-Handling
32+
sidekiq_options retry: 3
33+
34+
sidekiq_retries_exhausted do |msg|
35+
Rails.logger.error("[UnifiedHealthData] - #{msg['class']} failed with no retries left: #{msg['error_message']}")
36+
StatsD.increment('unified_health_data.facility_name_cache_job.failed_no_retries')
37+
end
38+
39+
def perform
40+
facility_map = fetch_vha_facilities
41+
cache_facility_names(facility_map)
42+
43+
Rails.logger.info("[UnifiedHealthData] - Cached #{facility_map.size} VHA facility names")
44+
StatsD.increment('unified_health_data.facility_name_cache_job.complete')
45+
StatsD.gauge('unified_health_data.facility_name_cache_job.facilities_cached', facility_map.size)
46+
rescue => e
47+
Rails.logger.error("[UnifiedHealthData] - Error in #{self.class.name}: #{e.message}")
48+
StatsD.increment('unified_health_data.facility_name_cache_job.error')
49+
raise "Failed to cache facility names: #{e.message}"
50+
end
51+
52+
private
53+
54+
def fetch_vha_facilities
55+
facilities_client = Lighthouse::Facilities::V1::Client.new
56+
all_facilities = []
57+
58+
Rails.logger.info('[UnifiedHealthData] - Fetching VHA facilities from Lighthouse API')
59+
60+
response = facilities_client.get_paginated_facilities(
61+
type: 'health',
62+
per_page: BATCH_SIZE,
63+
page: 1
64+
)
65+
66+
loop do
67+
all_facilities.concat(extract_vha_facilities(response))
68+
69+
# Check if there's a next page link
70+
break unless response.links&.dig('next')
71+
72+
response = fetch_next_page(facilities_client, response.links['next'])
73+
end
74+
75+
# Convert to hash for easy lookup
76+
all_facilities.to_h { |facility| [facility[:station_number], facility[:name]] }
77+
end
78+
79+
def extract_vha_facilities(response)
80+
response.facilities.filter_map do |facility|
81+
next unless facility.id.start_with?('vha_')
82+
83+
station_number = facility.id.sub(/^vha_/, '')
84+
{ station_number:, name: facility.name }
85+
end
86+
end
87+
88+
def fetch_next_page(client, next_url)
89+
next_url = URI.parse(next_url)
90+
next_params = URI.decode_www_form(next_url.query).to_h.transform_keys(&:to_sym)
91+
client.get_paginated_facilities(next_params)
92+
end
93+
94+
def cache_facility_names(facility_map)
95+
return if facility_map.empty?
96+
97+
Rails.logger.info('[UnifiedHealthData] - Caching facility names for 4 hours')
98+
99+
# Cache current facility names using Rails cache
100+
facility_map.each do |station_number, facility_name|
101+
cache_key = "#{CACHE_KEY_PREFIX}:#{station_number}"
102+
Rails.cache.write(cache_key, facility_name, expires_in: 4.hours)
103+
end
104+
105+
Rails.logger.info("[UnifiedHealthData] - Cache operation complete: #{facility_map.size} facilities cached")
106+
end
107+
end
108+
end

app/sidekiq/unified_health_data/labs_refresh_job.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def find_user(user_uuid)
3636

3737
def date_range
3838
end_date = Date.current
39-
days_back = Settings.mhv.uhd.labs_logging_date_range_days
39+
days_back = Settings.mhv.uhd.labs_logging_date_range_days.to_i
4040
start_date = end_date - days_back.days
4141
[start_date, end_date]
4242
end

lib/periodic_jobs.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,4 +278,8 @@
278278

279279
# Daily cron job to check for PDF Form version changes
280280
mgr.register('0 12 * * *', 'FormPdfChangeDetectionJob')
281+
282+
# Hourly job to cache facility names for UHD prescriptions
283+
# Runs at 37 minutes past the hour to avoid resource contention
284+
mgr.register('37 * * * *', 'UnifiedHealthData::FacilityNameCacheJob')
281285
}

lib/unified_health_data/adapters/oracle_health_prescription_adapter.rb

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require 'lighthouse/facilities/v1/client'
4+
35
module UnifiedHealthData
46
module Adapters
57
class OracleHealthPrescriptionAdapter
@@ -128,19 +130,30 @@ def extract_refill_remaining(resource)
128130
end
129131

130132
def extract_facility_name(resource)
131-
# Primary: dispenseRequest.performer
132-
performer_display = resource.dig('dispenseRequest', 'performer', 'display')
133-
return performer_display if performer_display
134-
135-
# Fallback: check contained Encounter for location
136-
if resource['contained']
137-
encounter = resource['contained'].find { |c| c['resourceType'] == 'Encounter' }
138-
if encounter
139-
location_display = encounter.dig('location', 0, 'location', 'display')
140-
return location_display if location_display
141-
end
133+
# Get latest dispense using existing helper
134+
latest_dispense = find_most_recent_medication_dispense(resource['contained'])
135+
return nil unless latest_dispense
136+
137+
# Get .location.display from latest dispense
138+
location_display = latest_dispense.dig('location', 'display')
139+
return nil unless location_display
140+
141+
# First try the legacy 3-digit station number
142+
three_digit_station = location_display.match(/^(\d{3})/)&.[](1)
143+
facility_name = attempt_facility_lookup(three_digit_station)
144+
return facility_name if facility_name
145+
146+
# If that fails, try the full facility identifier before the first hyphen (e.g., 648A4)
147+
facility_identifier = location_display.split('-').first
148+
# Valid format: 3 digits + up to 2 alpha (e.g., 648A, 648A4)
149+
valid_station_regex = /^\d{3}[A-Za-z0-9]{0,2}$/
150+
if facility_identifier.present? && facility_identifier != three_digit_station &&
151+
facility_identifier.match?(valid_station_regex)
152+
return attempt_facility_lookup(facility_identifier)
142153
end
143154

155+
Rails.logger.error("Unable to extract valid station number from: #{location_display}")
156+
144157
nil
145158
end
146159

@@ -286,6 +299,45 @@ def find_most_recent_medication_dispense(contained_resources)
286299
when_handed_over ? Time.zone.parse(when_handed_over) : Time.zone.at(0)
287300
end
288301
end
302+
303+
def fetch_facility_name_from_api(station_number)
304+
facility_id = "vha_#{station_number}"
305+
cache_key = "uhd:facility_names:#{station_number}"
306+
307+
begin
308+
facilities_client = Lighthouse::Facilities::V1::Client.new
309+
facilities = facilities_client.get_facilities(facilityIds: facility_id)
310+
311+
facility_name = if facilities&.any?
312+
facilities.first.name
313+
else
314+
Rails.logger.warn(
315+
"No facility found for station number #{station_number} in Lighthouse API"
316+
)
317+
nil
318+
end
319+
320+
# Cache the result (including nil) to avoid repeated API calls
321+
# Keep TTL aligned with FacilityNameCacheJob refresh cadence (4 hours)
322+
Rails.cache.write(cache_key, facility_name, expires_in: 4.hours)
323+
324+
facility_name
325+
rescue => e
326+
Rails.logger.error("Failed to fetch facility name from API for station #{station_number}: #{e.message}")
327+
StatsD.increment('unified_health_data.facility_name_fallback.api_error')
328+
nil
329+
end
330+
end
331+
332+
def attempt_facility_lookup(station_identifier)
333+
return nil if station_identifier.blank?
334+
335+
cache_key = "uhd:facility_names:#{station_identifier}"
336+
cached_name = Rails.cache.read(cache_key)
337+
return cached_name if Rails.cache.exist?(cache_key)
338+
339+
fetch_facility_name_from_api(station_identifier)
340+
end
289341
end
290342
end
291343
end

lib/unified_health_data/service.rb

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,16 @@ def get_prescriptions(current_only: false)
9696
end
9797

9898
def refill_prescription(orders)
99+
normalized_orders = normalize_orders(orders)
99100
with_monitoring do
100-
response = uhd_client.refill_prescription_orders(build_refill_request_body(orders))
101+
response = uhd_client.refill_prescription_orders(build_refill_request_body(normalized_orders))
101102
parse_refill_response(response)
102103
end
103104
rescue Common::Exceptions::BackendServiceException => e
104105
raise e if e.original_status && e.original_status >= 500
105106
rescue => e
106107
Rails.logger.error("Error submitting prescription refill: #{e.message}")
107-
build_error_response(orders)
108+
build_error_response(normalized_orders)
108109
end
109110

110111
def get_care_summaries_and_notes
@@ -279,8 +280,8 @@ def build_refill_request_body(orders)
279280
patientId: @user.icn,
280281
orders: orders.map do |order|
281282
{
282-
orderId: order['id'].to_s,
283-
stationNumber: order['stationNumber'].to_s
283+
orderId: order[:id].to_s,
284+
stationNumber: order[:stationNumber].to_s
284285
}
285286
end
286287
}
@@ -295,6 +296,16 @@ def build_error_response(orders)
295296
}
296297
end
297298

299+
def normalize_orders(orders)
300+
return [] if orders.blank?
301+
302+
orders.map do |order|
303+
next order unless order.respond_to?(:with_indifferent_access)
304+
305+
order.with_indifferent_access
306+
end
307+
end
308+
298309
def parse_refill_response(response)
299310
body = response.body
300311

0 commit comments

Comments
 (0)