his_emr_api_lab 1.1.22 → 1.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/MIT-LICENSE +20 -0
- data/README.md +71 -0
- data/Rakefile +32 -0
- data/app/controllers/lab/application_controller.rb +6 -0
- data/app/controllers/lab/labels_controller.rb +17 -0
- data/app/controllers/lab/orders_controller.rb +38 -0
- data/app/controllers/lab/reasons_for_test_controller.rb +9 -0
- data/app/controllers/lab/results_controller.rb +19 -0
- data/app/controllers/lab/specimen_types_controller.rb +15 -0
- data/app/controllers/lab/test_result_indicators_controller.rb +9 -0
- data/app/controllers/lab/test_types_controller.rb +15 -0
- data/app/controllers/lab/tests_controller.rb +26 -0
- data/app/jobs/lab/application_job.rb +4 -0
- data/app/jobs/lab/push_order_job.rb +12 -0
- data/app/jobs/lab/update_patient_orders_job.rb +32 -0
- data/app/jobs/lab/void_order_job.rb +17 -0
- data/app/mailers/lab/application_mailer.rb +6 -0
- data/app/models/lab/application_record.rb +5 -0
- data/app/models/lab/lab_accession_number_counter.rb +13 -0
- data/app/models/lab/lab_encounter.rb +7 -0
- data/app/models/lab/lab_order.rb +58 -0
- data/app/models/lab/lab_result.rb +31 -0
- data/app/models/lab/lab_test.rb +19 -0
- data/app/models/lab/lims_failed_import.rb +4 -0
- data/app/models/lab/lims_order_mapping.rb +10 -0
- data/app/serializers/lab/lab_order_serializer.rb +55 -0
- data/app/serializers/lab/result_serializer.rb +36 -0
- data/app/serializers/lab/test_serializer.rb +29 -0
- data/app/services/lab/accession_number_service.rb +77 -0
- data/app/services/lab/concepts_service.rb +82 -0
- data/app/services/lab/labelling_service/order_label.rb +106 -0
- data/app/services/lab/lims/api/blackhole_api.rb +21 -0
- data/app/services/lab/lims/api/couchdb_api.rb +53 -0
- data/app/services/lab/lims/api/mysql_api.rb +316 -0
- data/app/services/lab/lims/api/rest_api.rb +416 -0
- data/app/services/lab/lims/api/ws_api.rb +121 -0
- data/app/services/lab/lims/api_factory.rb +19 -0
- data/app/services/lab/lims/config.rb +100 -0
- data/app/services/lab/lims/exceptions.rb +11 -0
- data/app/services/lab/lims/migrator.rb +216 -0
- data/app/services/lab/lims/order_dto.rb +105 -0
- data/app/services/lab/lims/order_serializer.rb +244 -0
- data/app/services/lab/lims/pull_worker.rb +289 -0
- data/app/services/lab/lims/push_worker.rb +149 -0
- data/app/services/lab/lims/utils.rb +91 -0
- data/app/services/lab/lims/worker.rb +86 -0
- data/app/services/lab/metadata.rb +24 -0
- data/app/services/lab/orders_search_service.rb +66 -0
- data/app/services/lab/orders_service.rb +212 -0
- data/app/services/lab/results_service.rb +149 -0
- data/app/services/lab/tests_service.rb +93 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20210126092910_create_lab_lab_accession_number_counters.rb +12 -0
- data/db/migrate/20210310115457_create_lab_lims_order_mappings.rb +15 -0
- data/db/migrate/20210323080140_change_lims_id_to_string_in_lims_order_mapping.rb +15 -0
- data/db/migrate/20210326195504_add_order_revision_to_lims_order_mapping.rb +5 -0
- data/db/migrate/20210407071728_create_lab_lims_failed_imports.rb +19 -0
- data/db/migrate/20210610095024_fix_numeric_results_value_type.rb +20 -0
- data/db/migrate/20210807111531_add_default_to_lims_order_mapping.rb +7 -0
- data/lib/auto12epl.rb +201 -0
- data/lib/couch_bum/couch_bum.rb +92 -0
- data/lib/generators/lab/install/USAGE +9 -0
- data/lib/generators/lab/install/install_generator.rb +19 -0
- data/lib/generators/lab/install/templates/rswag-ui-lab.rb +5 -0
- data/lib/generators/lab/install/templates/start_worker.rb +32 -0
- data/lib/generators/lab/install/templates/swagger.yaml +714 -0
- data/lib/his_emr_api_lab.rb +5 -0
- data/lib/lab/engine.rb +15 -0
- data/lib/lab/version.rb +5 -0
- data/lib/logger_multiplexor.rb +38 -0
- data/lib/tasks/lab_tasks.rake +25 -0
- data/lib/tasks/loaders/data/reasons-for-test.csv +7 -0
- data/lib/tasks/loaders/data/test-measures.csv +225 -0
- data/lib/tasks/loaders/data/tests.csv +161 -0
- data/lib/tasks/loaders/loader_mixin.rb +53 -0
- data/lib/tasks/loaders/metadata_loader.rb +26 -0
- data/lib/tasks/loaders/reasons_for_test_loader.rb +23 -0
- data/lib/tasks/loaders/specimens_loader.rb +65 -0
- data/lib/tasks/loaders/test_result_indicators_loader.rb +54 -0
- metadata +81 -2
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Lab::Lims::Api::RestApi
|
|
4
|
+
class LimsApiError < GatewayError; end
|
|
5
|
+
|
|
6
|
+
class AuthenticationTokenExpired < LimsApiError; end
|
|
7
|
+
|
|
8
|
+
class InvalidParameters < LimsApiError; end
|
|
9
|
+
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_order(order_dto)
|
|
15
|
+
response = in_authenticated_session do |headers|
|
|
16
|
+
Rails.logger.info("Pushing order ##{order_dto[:tracking_number]} to LIMS")
|
|
17
|
+
|
|
18
|
+
if order_dto['sample_type'].casecmp?('not_specified')
|
|
19
|
+
RestClient.post(expand_uri('request_order', api_version: 'v2'), make_create_params(order_dto), headers)
|
|
20
|
+
else
|
|
21
|
+
RestClient.post(expand_uri('create_order'), make_create_params(order_dto), headers)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
data = JSON.parse(response.body)
|
|
26
|
+
update_order_results(order_dto) unless data['message'].casecmp?('Order already available')
|
|
27
|
+
|
|
28
|
+
ActiveSupport::HashWithIndifferentAccess.new(
|
|
29
|
+
id: order_dto.fetch(:_id, order_dto[:tracking_number]),
|
|
30
|
+
rev: 0,
|
|
31
|
+
tracking_number: order_dto[:tracking_number]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def update_order(_id, order_dto)
|
|
36
|
+
in_authenticated_session do |headers|
|
|
37
|
+
RestClient.post(expand_uri('update_order'), make_update_params(order_dto), headers)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
update_order_results(order_dto)
|
|
41
|
+
|
|
42
|
+
{ tracking_number: order_dto[:tracking_number] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def consume_orders(*_args, patient_id: nil, **_kwargs)
|
|
46
|
+
orders_pending_updates(patient_id).each do |order|
|
|
47
|
+
order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
|
|
48
|
+
|
|
49
|
+
if order_dto['priority'].nil? || order_dto['sample_type'].casecmp?('not_specified')
|
|
50
|
+
patch_order_dto_with_lims_order!(order_dto, find_lims_order(order.accession_number))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if order_dto['test_results'].empty?
|
|
54
|
+
begin
|
|
55
|
+
patch_order_dto_with_lims_results!(order_dto, find_lims_results(order.accession_number))
|
|
56
|
+
rescue InvalidParameters => e # LIMS responds with a 401 when a result is not found :(
|
|
57
|
+
Rails.logger.error("Failed to fetch results for ##{order.accession_number}: #{e.message}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
yield order_dto, OpenStruct.new(last_seq: 0)
|
|
62
|
+
rescue LimsApiError => e
|
|
63
|
+
Rails.logger.error("Failed to fetch updates for ##{order.accession_number}: #{e.class} - #{e.message}")
|
|
64
|
+
sleep(1)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def delete_order(_id, order_dto)
|
|
69
|
+
tracking_number = order_dto.fetch('tracking_number')
|
|
70
|
+
|
|
71
|
+
order_dto['tests'].each do |test|
|
|
72
|
+
Rails.logger.info("Voiding test '#{test}' (#{tracking_number}) in LIMS")
|
|
73
|
+
in_authenticated_session do |headers|
|
|
74
|
+
date_voided, voided_status = find_test_status(order_dto, test, 'Voided')
|
|
75
|
+
params = make_void_test_params(tracking_number, test, voided_status['updated_by'], date_voided)
|
|
76
|
+
RestClient.post(expand_uri('update_test'), params, headers)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
attr_reader :config
|
|
84
|
+
|
|
85
|
+
MAX_LIMS_RETRIES = 5 # LIMS API Calls can only fail this number of times before we give up on it
|
|
86
|
+
|
|
87
|
+
##
|
|
88
|
+
# Execute LIMS API calls within an authenticated session.
|
|
89
|
+
#
|
|
90
|
+
# Method automatically checks authenticates with LIMS if necessary and passes
|
|
91
|
+
# down the necessary headers for authentication to the REST call being made.
|
|
92
|
+
#
|
|
93
|
+
# Example:
|
|
94
|
+
#
|
|
95
|
+
# response = in_authenticated_session do |headers|
|
|
96
|
+
# RestClient.get(expand_uri('query_results_by_tracking_number/XXXXXX'), headers)
|
|
97
|
+
# end
|
|
98
|
+
#
|
|
99
|
+
# pp JSON.parse(response.body) if response.code == 200
|
|
100
|
+
def in_authenticated_session
|
|
101
|
+
retries ||= MAX_LIMS_RETRIES
|
|
102
|
+
|
|
103
|
+
self.authentication_token = authenticate unless authentication_token
|
|
104
|
+
|
|
105
|
+
response = yield 'token' => authentication_token, 'Content-type' => 'application/json'
|
|
106
|
+
check_response!(response)
|
|
107
|
+
rescue AuthenticationTokenExpired => e
|
|
108
|
+
self.authentication_token = nil
|
|
109
|
+
retry if (retries -= 1).positive?
|
|
110
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
111
|
+
Rails.logger.error("LIMS Error: #{e.response&.code} - #{e.response&.body}")
|
|
112
|
+
raise e unless e.response&.code == 401
|
|
113
|
+
|
|
114
|
+
self.authentication_token = nil
|
|
115
|
+
retry if (retries -= 1).positive?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def authenticate
|
|
119
|
+
username = config.fetch(:username)
|
|
120
|
+
password = config.fetch(:password)
|
|
121
|
+
|
|
122
|
+
Rails.logger.debug("Authenticating with LIMS as: #{username}")
|
|
123
|
+
response = RestClient.get(expand_uri("re_authenticate/#{username}/#{password}"),
|
|
124
|
+
headers: { 'Content-type' => 'application/json' })
|
|
125
|
+
response_body = JSON.parse(response.body)
|
|
126
|
+
|
|
127
|
+
if response_body['status'] == 401
|
|
128
|
+
Rails.logger.error("Failed to authenticate with LIMS as #{config.fetch(:username)}: #{response_body['message']}")
|
|
129
|
+
raise LimsApiError, 'LIMS authentication failed'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
response_body['data']['token']
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def authentication_token=(token)
|
|
136
|
+
Thread.current[:lims_authentication_token] = token
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def authentication_token
|
|
140
|
+
Thread.current[:lims_authentication_token]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
##
|
|
144
|
+
# Examines a response from LIMS to check if token has expired.
|
|
145
|
+
#
|
|
146
|
+
# LIMS' doesn't properly use HTTP status codes; the codes are embedded in the
|
|
147
|
+
# response body. 200 is used for success responses and 401 for everything else.
|
|
148
|
+
# We have this work around to examine the response body and
|
|
149
|
+
# throw errors accordingly. The following are the errors thrown:
|
|
150
|
+
#
|
|
151
|
+
# Lims::AuthenticationTokenExpired
|
|
152
|
+
# Lims::InvalidParameters
|
|
153
|
+
# Lims::ApiError - Thrown when we couldn't make sense of the error
|
|
154
|
+
def check_response!(response)
|
|
155
|
+
body = JSON.parse(response.body)
|
|
156
|
+
return response if body['status'] == 200
|
|
157
|
+
|
|
158
|
+
Rails.logger.error("Lims Api Error: #{response.body}")
|
|
159
|
+
|
|
160
|
+
raise LimsApiError, "#{body['status']} - #{body['message']}" if body['status'] != 401
|
|
161
|
+
|
|
162
|
+
if body['message'].match?(/token expired/i)
|
|
163
|
+
raise AuthenticationTokenExpired, "Authentication token expired: #{body['message']}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
raise InvalidParameters, body['message']
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
##
|
|
170
|
+
# Takes a LIMS API relative URI and converts it to a full URL.
|
|
171
|
+
def expand_uri(uri, api_version: 'v1')
|
|
172
|
+
protocol = config.fetch(:protocol)
|
|
173
|
+
host = config.fetch(:host)
|
|
174
|
+
port = config.fetch(:port)
|
|
175
|
+
uri = uri.gsub(%r{^/+}, '')
|
|
176
|
+
|
|
177
|
+
"#{protocol}://#{host}:#{port}/api/#{api_version}/#{uri}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
##
|
|
181
|
+
# Converts an OrderDTO to parameters for POST /create_order
|
|
182
|
+
def make_create_params(order_dto)
|
|
183
|
+
{
|
|
184
|
+
tracking_number: order_dto.fetch(:tracking_number),
|
|
185
|
+
district: current_district,
|
|
186
|
+
health_facility_name: order_dto.fetch(:sending_facility),
|
|
187
|
+
first_name: order_dto.fetch(:patient).fetch(:first_name),
|
|
188
|
+
last_name: order_dto.fetch(:patient).fetch(:last_name),
|
|
189
|
+
phone_number: order_dto.fetch(:patient).fetch(:phone_number),
|
|
190
|
+
gender: order_dto.fetch(:patient).fetch(:gender),
|
|
191
|
+
arv_number: order_dto.fetch(:patient).fetch(:arv_number),
|
|
192
|
+
art_regimen: order_dto.fetch(:patient).fetch(:art_regimen),
|
|
193
|
+
art_start_date: order_dto.fetch(:patient).fetch(:art_start_date),
|
|
194
|
+
national_patient_id: order_dto.fetch(:patient).fetch(:id),
|
|
195
|
+
requesting_clinician: requesting_clinician(order_dto),
|
|
196
|
+
sample_type: order_dto.fetch(:sample_type),
|
|
197
|
+
tests: order_dto.fetch(:tests),
|
|
198
|
+
date_sample_drawn: sample_drawn_date(order_dto),
|
|
199
|
+
sample_priority: order_dto.fetch(:priority) || 'Routine',
|
|
200
|
+
sample_status: order_dto.fetch(:sample_status),
|
|
201
|
+
target_lab: order_dto.fetch(:receiving_facility),
|
|
202
|
+
order_location: order_dto.fetch(:order_location) || 'Unknown',
|
|
203
|
+
who_order_test_first_name: order_dto.fetch(:who_order_test).fetch(:first_name),
|
|
204
|
+
who_order_test_last_name: order_dto.fetch(:who_order_test).fetch(:last_name)
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
##
|
|
209
|
+
# Converts an OrderDTO to parameters for POST /update_order
|
|
210
|
+
def make_update_params(order_dto)
|
|
211
|
+
date_updated, status = sample_drawn_status(order_dto)
|
|
212
|
+
|
|
213
|
+
{
|
|
214
|
+
tracking_number: order_dto.fetch(:tracking_number),
|
|
215
|
+
who_updated: status.fetch(:updated_by),
|
|
216
|
+
date_updated: date_updated,
|
|
217
|
+
specimen_type: order_dto.fetch(:sample_type),
|
|
218
|
+
status: 'specimen_collected'
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def current_district
|
|
223
|
+
health_centre = Location.current_health_center
|
|
224
|
+
raise 'Current health centre not set' unless health_centre
|
|
225
|
+
|
|
226
|
+
district = health_centre.district || Lab::Lims::Config.application['district']
|
|
227
|
+
|
|
228
|
+
unless district
|
|
229
|
+
health_centre_name = "##{health_centre.id} - #{health_centre.name}"
|
|
230
|
+
raise "Current health centre district not set: #{health_centre_name}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
district
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
##
|
|
237
|
+
# Extracts sample drawn status from an OrderDTO
|
|
238
|
+
def sample_drawn_status(order_dto)
|
|
239
|
+
order_dto[:sample_statuses].each do |trail_entry|
|
|
240
|
+
date, status = trail_entry.each_pair.find { |_date, status| status['status'].casecmp?('Drawn') }
|
|
241
|
+
next unless date
|
|
242
|
+
|
|
243
|
+
return Date.strptime(date, '%Y%m%d%H%M%S').strftime('%Y-%m-%d'), status
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
[order_dto['date_created'], nil]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
##
|
|
250
|
+
# Extracts a sample drawn date from a LIMS OrderDTO.
|
|
251
|
+
def sample_drawn_date(order_dto)
|
|
252
|
+
sample_drawn_status(order_dto).first
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
##
|
|
256
|
+
# Extracts the requesting clinician from a LIMS OrderDTO
|
|
257
|
+
def requesting_clinician(order_dto)
|
|
258
|
+
orderer = order_dto[:who_order_test]
|
|
259
|
+
|
|
260
|
+
"#{orderer[:first_name]} #{orderer[:last_name]}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def find_lims_order(tracking_number)
|
|
264
|
+
response = in_authenticated_session do |headers|
|
|
265
|
+
Rails.logger.info("Fetching order ##{tracking_number}")
|
|
266
|
+
RestClient.get(expand_uri("query_order_by_tracking_number/#{tracking_number}"), headers)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
Rails.logger.info("Order ##{tracking_number} found... Parsing...")
|
|
270
|
+
JSON.parse(response).fetch('data')
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def find_lims_results(tracking_number)
|
|
274
|
+
response = in_authenticated_session do |headers|
|
|
275
|
+
Rails.logger.info("Fetching results for order ##{tracking_number}")
|
|
276
|
+
RestClient.get(expand_uri("query_results_by_tracking_number/#{tracking_number}"), headers)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
Rails.logger.info("Result for order ##{tracking_number} found... Parsing...")
|
|
280
|
+
JSON.parse(response).fetch('data').fetch('results')
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
##
|
|
284
|
+
# Make a copy of the order_dto with the results from LIMS parsed
|
|
285
|
+
# and appended to it.
|
|
286
|
+
def patch_order_dto_with_lims_results!(order_dto, results)
|
|
287
|
+
order_dto.merge!(
|
|
288
|
+
'_id' => order_dto[:tracking_number],
|
|
289
|
+
'_rev' => 0,
|
|
290
|
+
'test_results' => results.each_with_object({}) do |result, formatted_results|
|
|
291
|
+
test_name, measures = result
|
|
292
|
+
result_date = measures.delete('result_date')
|
|
293
|
+
|
|
294
|
+
formatted_results[test_name] = {
|
|
295
|
+
results: measures.each_with_object({}) do |measure, processed_measures|
|
|
296
|
+
processed_measures[measure[0]] = { 'result_value' => measure[1] }
|
|
297
|
+
end,
|
|
298
|
+
result_date: result_date,
|
|
299
|
+
result_entered_by: {}
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def patch_order_dto_with_lims_order!(order_dto, lims_order)
|
|
306
|
+
order_dto.merge!(
|
|
307
|
+
'sample_type' => lims_order['other']['sample_type'],
|
|
308
|
+
'sample_status' => lims_order['other']['specimen_status'],
|
|
309
|
+
'priority' => lims_order['other']['priority']
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def update_order_results(order_dto)
|
|
314
|
+
return nil if order_dto['test_results'].nil? || order_dto['test_results'].empty?
|
|
315
|
+
|
|
316
|
+
order_dto['test_results'].each do |test_name, results|
|
|
317
|
+
Rails.logger.info("Pushing result for order ##{order_dto['tracking_number']}")
|
|
318
|
+
in_authenticated_session do |headers|
|
|
319
|
+
params = make_update_test_params(order_dto['tracking_number'], test_name, results)
|
|
320
|
+
|
|
321
|
+
RestClient.post(expand_uri('update_test'), params, headers)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def make_update_test_params(tracking_number, test_name, results, test_status = 'Drawn')
|
|
327
|
+
{
|
|
328
|
+
tracking_number: tracking_number,
|
|
329
|
+
test_name: test_name,
|
|
330
|
+
result_date: results['result_date'],
|
|
331
|
+
time_updated: results['result_date'],
|
|
332
|
+
who_updated: {
|
|
333
|
+
first_name: results[:result_entered_by][:first_name],
|
|
334
|
+
last_name: results[:result_entered_by][:last_name],
|
|
335
|
+
id_number: results[:result_entered_by][:id]
|
|
336
|
+
},
|
|
337
|
+
test_status: test_status,
|
|
338
|
+
results: results['results']&.each_with_object({}) do |measure, formatted_results|
|
|
339
|
+
measure_name, measure_value = measure
|
|
340
|
+
|
|
341
|
+
formatted_results[measure_name] = measure_value['result_value']
|
|
342
|
+
end
|
|
343
|
+
}
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def find_test_status(order_dto, target_test, target_status)
|
|
347
|
+
order_dto['test_statuses'].each do |test, statuses|
|
|
348
|
+
next unless test.casecmp?(target_test)
|
|
349
|
+
|
|
350
|
+
statuses.each do |date, status|
|
|
351
|
+
next unless status['status'].casecmp?(target_status)
|
|
352
|
+
|
|
353
|
+
return [Date.strptime(date, '%Y%m%d%H%M%S'), status]
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def make_void_test_params(tracking_number, test_name, voided_by, void_date = nil)
|
|
361
|
+
void_date ||= Time.now
|
|
362
|
+
|
|
363
|
+
{
|
|
364
|
+
tracking_number: tracking_number,
|
|
365
|
+
test_name: test_name,
|
|
366
|
+
time_updated: void_date,
|
|
367
|
+
who_updated: {
|
|
368
|
+
first_name: voided_by[:first_name],
|
|
369
|
+
last_name: voided_by[:last_name],
|
|
370
|
+
id_number: voided_by[:id]
|
|
371
|
+
},
|
|
372
|
+
test_status: 'voided'
|
|
373
|
+
}
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def orders_pending_updates(patient_id = nil)
|
|
377
|
+
Rails.logger.info('Looking for orders that need to be updated...')
|
|
378
|
+
orders = {}
|
|
379
|
+
|
|
380
|
+
orders_without_specimen(patient_id).each { |order| orders[order.order_id] = order }
|
|
381
|
+
orders_without_results(patient_id).each { |order| orders[order.order_id] = order }
|
|
382
|
+
orders_without_reason(patient_id).each { |order| orders[order.order_id] = order }
|
|
383
|
+
|
|
384
|
+
orders.values
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def orders_without_specimen(patient_id = nil)
|
|
388
|
+
Rails.logger.debug('Looking for orders without a specimen')
|
|
389
|
+
unknown_specimen = ConceptName.where(name: Lab::Metadata::UNKNOWN_SPECIMEN)
|
|
390
|
+
.select(:concept_id)
|
|
391
|
+
orders = Lab::LabOrder.where(concept_id: unknown_specimen)
|
|
392
|
+
.where.not(accession_number: Lab::LimsOrderMapping.select(:lims_id))
|
|
393
|
+
orders = orders.where(patient_id: patient_id) if patient_id
|
|
394
|
+
|
|
395
|
+
orders
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def orders_without_results(patient_id = nil)
|
|
399
|
+
Rails.logger.debug('Looking for orders without a result')
|
|
400
|
+
# Lab::OrdersSearchService.find_orders_without_results(patient_id: patient_id)
|
|
401
|
+
# .where.not(accession_number: Lab::LimsOrderMapping.select(:lims_id).where("pulled_at IS NULL"))
|
|
402
|
+
Lab::OrdersSearchService.find_orders_without_results(patient_id: patient_id)
|
|
403
|
+
.where(order_id: Lab::LimsOrderMapping.select(:order_id))
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def orders_without_reason(patient_id = nil)
|
|
407
|
+
Rails.logger.debug('Looking for orders without a reason for test')
|
|
408
|
+
orders = Lab::LabOrder.joins(:reason_for_test)
|
|
409
|
+
.merge(Observation.where(value_coded: nil, value_text: nil))
|
|
410
|
+
.limit(1000)
|
|
411
|
+
.where.not(accession_number: Lab::LimsOrderMapping.select(:lims_id))
|
|
412
|
+
orders = orders.where(patient_id: patient_id) if patient_id
|
|
413
|
+
|
|
414
|
+
orders
|
|
415
|
+
end
|
|
416
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket.io-client-simple'
|
|
4
|
+
|
|
5
|
+
module Lab
|
|
6
|
+
module Lims
|
|
7
|
+
module Api
|
|
8
|
+
##
|
|
9
|
+
# Retrieve results from LIMS only through a websocket
|
|
10
|
+
class WsApi
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
@results_queue = []
|
|
14
|
+
@socket = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def consume_orders(**_kwargs)
|
|
18
|
+
loop do
|
|
19
|
+
results = fetch_results
|
|
20
|
+
unless results
|
|
21
|
+
Rails.logger.debug('No results available... Waiting for results...')
|
|
22
|
+
sleep(Lab::Lims::Config.updates_poll_frequency)
|
|
23
|
+
next
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Rails.logger.info("Received result for ##{results['tracking_number']}")
|
|
27
|
+
order = find_order(results['tracking_number'])
|
|
28
|
+
next unless order
|
|
29
|
+
|
|
30
|
+
Rails.logger.info("Updating result for order ##{order.order_id}")
|
|
31
|
+
yield make_order_dto(order, results), OpenStruct.new(last_seq: 1)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def initialize_socket
|
|
38
|
+
Rails.logger.debug('Establishing connection to socket...')
|
|
39
|
+
socket = SocketIO::Client::Simple.connect(socket_url)
|
|
40
|
+
socket.on(:connect, &method(:on_socket_connect))
|
|
41
|
+
socket.on(:disconnect, &method(:on_socket_disconnect))
|
|
42
|
+
socket.on(:results, &method(:on_results_received))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def socket_url
|
|
46
|
+
@config.fetch('url')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def on_socket_connect
|
|
50
|
+
Rails.logger.debug('Connection to LIMS results socket established...')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def on_socket_disconnect
|
|
54
|
+
Rails.logger.debug('Connection to LIMS results socket lost...')
|
|
55
|
+
@socket = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def on_results_received(result)
|
|
59
|
+
Rails.logger.debug("Received result from LIMS: #{result}")
|
|
60
|
+
tracking_number = result['tracking_number']
|
|
61
|
+
|
|
62
|
+
Rails.logger.debug("Queueing result for order ##{tracking_number}")
|
|
63
|
+
@results_queue.push(result)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def order_exists?(tracking_number)
|
|
67
|
+
Rails.logger.debug("Looking for order for result ##{tracking_number}")
|
|
68
|
+
orders = OrdersSearchService.find_orders_without_results
|
|
69
|
+
.where(accession_number: tracking_number)
|
|
70
|
+
# The following ensures that the order was previously pushed to LIMS
|
|
71
|
+
# or was received from LIMS
|
|
72
|
+
Lab::LimsOrderMapping.where.not(order: orders).exists?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fetch_results
|
|
76
|
+
loop do
|
|
77
|
+
@socket ||= initialize_socket
|
|
78
|
+
|
|
79
|
+
results = @results_queue.shift
|
|
80
|
+
return nil unless results
|
|
81
|
+
|
|
82
|
+
unless order_exists?(results['tracking_number'])
|
|
83
|
+
Rails.logger.debug("Ignoring result for order ##{tracking_number}")
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return results
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def find_order(lims_id)
|
|
92
|
+
mapping = Lab::LimsOrderMapping.where(lims_id: lims_id).select(:order_id)
|
|
93
|
+
Lab::LabOrder.find_by(order_id: mapping)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def make_order_dto(order, results)
|
|
97
|
+
Lab::Lims::OrderSerializer
|
|
98
|
+
.serialize_order(order)
|
|
99
|
+
.merge(
|
|
100
|
+
id: order.accession_number,
|
|
101
|
+
test_results: {
|
|
102
|
+
results['test_name'] => {
|
|
103
|
+
results: results['results'].each_with_object({}) do |measure, formatted_measures|
|
|
104
|
+
measure_name, measure_value = measure
|
|
105
|
+
|
|
106
|
+
formatted_measures[measure_name] = { result_value: measure_value }
|
|
107
|
+
end,
|
|
108
|
+
result_date: results['date_updated'],
|
|
109
|
+
result_entered_by: {
|
|
110
|
+
first_name: results['who_updated']['first_name'],
|
|
111
|
+
last_name: results['who_updated']['last_name'],
|
|
112
|
+
id: results['who_updated']['id_number']
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lab
|
|
4
|
+
module Lims
|
|
5
|
+
##
|
|
6
|
+
# Creates LIMS Apis based on current configuration
|
|
7
|
+
module ApiFactory
|
|
8
|
+
def self.create_api
|
|
9
|
+
return Lab::Lims::Api::BlackholeApi.new if Rails.env.casecmp?('test')
|
|
10
|
+
|
|
11
|
+
case Lab::Lims::Config.preferred_api
|
|
12
|
+
when /rest/i then Lab::Lims::Api::RestApi.new(Lab::Lims::Config.rest_api)
|
|
13
|
+
when /couchdb/ then Lab::Lims::Api::CouchDbApi.new(config: Lab::Lims::Config.couchdb_api)
|
|
14
|
+
else raise "Invalid lims_api configuration: #{Lab::Lims::Config.preferred_api}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lab
|
|
4
|
+
module Lims
|
|
5
|
+
##
|
|
6
|
+
# Load LIMS' configuration files
|
|
7
|
+
module Config
|
|
8
|
+
# TODO: Replace this maybe with `Rails.application.configuration.lab.lims`
|
|
9
|
+
# so that we do not have to directly mess with configuration files.
|
|
10
|
+
|
|
11
|
+
class ConfigNotFound < RuntimeError; end
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def preferred_api
|
|
15
|
+
emr_api_application('lims_api')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# Returns LIMS' couchdb configuration file for the current environment (Rails.env)
|
|
20
|
+
def couchdb
|
|
21
|
+
config_path = begin
|
|
22
|
+
find_config_path('couchdb.yml')
|
|
23
|
+
rescue ConfigNotFound => e
|
|
24
|
+
Rails.logger.error("Failed to find default LIMS couchdb config: #{e.message}")
|
|
25
|
+
find_config_path('couchdb-lims.yml') # This can be placed in HIS-EMR-API/config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Rails.logger.debug("Using LIMS couchdb config: #{config_path}")
|
|
29
|
+
|
|
30
|
+
YAML.load_file(config_path)[Rails.env]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def rest_api
|
|
34
|
+
@rest_api ||= {
|
|
35
|
+
protocol: emr_api_application('lims_protocol', 'http'),
|
|
36
|
+
host: emr_api_application('lims_host'),
|
|
37
|
+
port: emr_api_application('lims_port'),
|
|
38
|
+
username: emr_api_application('lims_username'),
|
|
39
|
+
password: emr_api_application('lims_password')
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def updates_socket
|
|
44
|
+
@updates_socket ||= {
|
|
45
|
+
'url' => emr_api_application('lims_realtime_updates_url')
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def updates_poll_frequency
|
|
50
|
+
30 # Seconds
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# Returns LIMS' application.yml configuration file
|
|
55
|
+
def application
|
|
56
|
+
@application ||= YAML.load_file(find_config_path('application.yml'))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# Returns LIMS' database.yml configuration file
|
|
61
|
+
def database
|
|
62
|
+
@database ||= YAML.load_file(find_config_path('database.yml'))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def emr_api_application(param, fallback = nil)
|
|
68
|
+
@emr_api_application ||= YAML.load_file(Rails.root.join('config', 'application.yml'))
|
|
69
|
+
|
|
70
|
+
@emr_api_application.fetch(param) do
|
|
71
|
+
raise ConfigNotFound, "Missing config param: #{param}" unless fallback
|
|
72
|
+
|
|
73
|
+
fallback
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
# Looks for a config file in various LIMS installation directories
|
|
79
|
+
#
|
|
80
|
+
# Returns: a path to a file found
|
|
81
|
+
def find_config_path(filename)
|
|
82
|
+
paths = [
|
|
83
|
+
"#{ENV['HOME']}/apps/nlims_controller/config/#{filename}",
|
|
84
|
+
"/var/www/nlims_controller/config/#{filename}",
|
|
85
|
+
Rails.root.parent.join("nlims_controller/config/#{filename}")
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
paths = [Rails.root.join('config/lims-couchdb.yml'), *paths] if filename == 'couchdb.yml'
|
|
89
|
+
|
|
90
|
+
paths.each do |path|
|
|
91
|
+
Rails.logger.debug("Looking for LIMS couchdb config at: #{path}")
|
|
92
|
+
return path if File.exist?(path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
raise ConfigNotFound, "Could not find a configuration file, checked: #{paths.join(':')}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lab
|
|
4
|
+
module Lims
|
|
5
|
+
class LimsException < StandardError; end
|
|
6
|
+
class DuplicateNHID < LimsException; end
|
|
7
|
+
class MissingAccessionNumber < LimsException; end
|
|
8
|
+
class UnknownSpecimenType < LimsException; end
|
|
9
|
+
class UnknownTestType < LimsException; end
|
|
10
|
+
end
|
|
11
|
+
end
|