his_emr_api_lab 0.0.2 → 0.0.3
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/app/controllers/lab/labels_controller.rb +15 -0
- data/app/controllers/lab/orders_controller.rb +2 -1
- data/app/models/lab/lab_order.rb +5 -1
- data/app/models/lab/lab_result.rb +10 -0
- data/app/models/lab/lab_test.rb +5 -0
- data/app/services/lab/labelling_service/order_label.rb +85 -0
- data/app/services/lab/lims/api.rb +7 -0
- data/app/services/lab/lims/exceptions.rb +11 -0
- data/app/services/lab/lims/migrator.rb +206 -0
- data/app/services/lab/lims/order_dto.rb +41 -122
- data/app/services/lab/lims/order_serializer.rb +28 -2
- data/app/services/lab/lims/utils.rb +45 -1
- data/app/services/lab/lims/worker.rb +227 -38
- data/app/services/lab/metadata.rb +1 -1
- data/app/services/lab/orders_service.rb +9 -8
- data/app/services/lab/results_service.rb +38 -6
- data/config/routes.rb +2 -0
- data/lib/auto12epl.rb +201 -0
- data/lib/couch_bum/couch_bum.rb +11 -1
- data/lib/lab/version.rb +1 -1
- data/lib/tasks/loaders/data/tests.csv +21 -0
- metadata +21 -2
@@ -1,5 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative './order_dto'
|
4
|
+
require_relative './utils'
|
5
|
+
|
3
6
|
module Lab
|
4
7
|
module Lims
|
5
8
|
##
|
@@ -9,7 +12,7 @@ module Lab
|
|
9
12
|
include Utils
|
10
13
|
|
11
14
|
def serialize_order(order)
|
12
|
-
serialized_order = structify(Lab::LabOrderSerializer.serialize_order(order))
|
15
|
+
serialized_order = Utils.structify(Lab::LabOrderSerializer.serialize_order(order))
|
13
16
|
|
14
17
|
OrderDTO.new(
|
15
18
|
tracking_number: serialized_order.accession_number,
|
@@ -20,6 +23,7 @@ module Lab
|
|
20
23
|
order_location: format_order_location(serialized_order.encounter_id),
|
21
24
|
sample_type: format_sample_type(serialized_order.specimen.name),
|
22
25
|
sample_status: format_sample_status(serialized_order.specimen.name),
|
26
|
+
sample_statuses: format_sample_status_trail(order),
|
23
27
|
districy: current_district, # yes districy [sic]...
|
24
28
|
priority: serialized_order.reason_for_test.name,
|
25
29
|
date_created: serialized_order.order_date,
|
@@ -56,7 +60,7 @@ module Lab
|
|
56
60
|
first_name: name&.given_name,
|
57
61
|
last_name: name&.family_name,
|
58
62
|
id: national_id&.identifier,
|
59
|
-
phone_number: phone_number
|
63
|
+
phone_number: phone_number&.value,
|
60
64
|
gender: person.gender,
|
61
65
|
email: nil
|
62
66
|
}
|
@@ -70,6 +74,28 @@ module Lab
|
|
70
74
|
name.casecmp?('Unknown') ? 'specimen_not_collected' : 'specimen_collected'
|
71
75
|
end
|
72
76
|
|
77
|
+
def format_sample_status_trail(order)
|
78
|
+
if order.concept_id == ConceptName.find_by_name!('Unknown').concept_id
|
79
|
+
return []
|
80
|
+
end
|
81
|
+
|
82
|
+
user = User.find(order.discontinued_by || order.creator)
|
83
|
+
drawn_by = PersonName.find_by_person_id(user.user_id)
|
84
|
+
drawn_date = order.discontinued_date || order.start_date
|
85
|
+
|
86
|
+
[
|
87
|
+
drawn_date.strftime('%Y%m%d%H%M%S') => {
|
88
|
+
'status' => 'Drawn',
|
89
|
+
'updated_by' => {
|
90
|
+
'first_name' => drawn_by&.given_name || user.username,
|
91
|
+
'last_name' => drawn_by&.family_name,
|
92
|
+
'phone_number' => nil,
|
93
|
+
'id' => user.username
|
94
|
+
}
|
95
|
+
}
|
96
|
+
]
|
97
|
+
end
|
98
|
+
|
73
99
|
def format_test_results(order)
|
74
100
|
order.tests.each_with_object({}) do |test, results|
|
75
101
|
results[test.name] = {
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'cgi/util'
|
4
|
+
|
3
5
|
module Lab
|
4
6
|
module Lims
|
5
7
|
##
|
@@ -9,7 +11,21 @@ module Lab
|
|
9
11
|
Rails.logger
|
10
12
|
end
|
11
13
|
|
12
|
-
|
14
|
+
TEST_NAME_MAPPINGS = {
|
15
|
+
# For some weird reason(s) some tests have multiple names in LIMS,
|
16
|
+
# this is used to sanitize those names.
|
17
|
+
'hiv_viral_load' => 'HIV Viral Load',
|
18
|
+
'viral laod' => 'HIV Viral Load',
|
19
|
+
'viral load' => 'HIV Viral Load',
|
20
|
+
'i/ink' => 'India ink',
|
21
|
+
'indian ink' => 'India ink'
|
22
|
+
}.freeze
|
23
|
+
|
24
|
+
def self.translate_test_name(test_name)
|
25
|
+
TEST_NAME_MAPPINGS.fetch(test_name.downcase, test_name)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.structify(object)
|
13
29
|
if object.is_a?(Hash)
|
14
30
|
object.each_with_object(OpenStruct.new) do |kv_pair, struct|
|
15
31
|
key, value = kv_pair
|
@@ -22,6 +38,34 @@ module Lab
|
|
22
38
|
object
|
23
39
|
end
|
24
40
|
end
|
41
|
+
|
42
|
+
def self.parse_date(str_date, fallback_date = nil)
|
43
|
+
if str_date.blank? && fallback_date.blank?
|
44
|
+
raise "Can't parse blank date"
|
45
|
+
end
|
46
|
+
|
47
|
+
return parse_date(fallback_date) if str_date.blank?
|
48
|
+
|
49
|
+
str_date = str_date.gsub(/^00/, '20').gsub(/^180/, '20')
|
50
|
+
|
51
|
+
if str_date.match?(/\d{4}-\d{2}-\d{2}/)
|
52
|
+
str_date
|
53
|
+
elsif str_date.match?(/\d{2}-\d{2}-\d{2}/)
|
54
|
+
Date.strptime(str_date, '%d-%m-%Y').strftime('%Y-%m-%d')
|
55
|
+
elsif str_date.match?(/(\d{4}\d{2}\d{2})\d+/)
|
56
|
+
Date.strptime(str_date, '%Y%m%d').strftime('%Y-%m-%d')
|
57
|
+
else
|
58
|
+
Rails.logger.warn("Invalid date: #{str_date}")
|
59
|
+
parse_date(fallback_date)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.find_concept_by_name(name)
|
64
|
+
ConceptName.joins(:concept)
|
65
|
+
.merge(Concept.all) # Filter out voided
|
66
|
+
.where(name: CGI.unescapeHTML(name))
|
67
|
+
.first
|
68
|
+
end
|
25
69
|
end
|
26
70
|
end
|
27
71
|
end
|
@@ -1,5 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'cgi/util'
|
4
|
+
|
5
|
+
require_relative './exceptions'
|
6
|
+
require_relative './order_serializer'
|
3
7
|
require_relative './utils'
|
4
8
|
|
5
9
|
module Lab
|
@@ -7,14 +11,14 @@ module Lab
|
|
7
11
|
##
|
8
12
|
# Pull/Push orders from/to the LIMS queue (Oops meant CouchDB).
|
9
13
|
class Worker
|
10
|
-
def initialize(lims_api)
|
11
|
-
@lims_api = lims_api
|
12
|
-
end
|
13
|
-
|
14
14
|
include Utils
|
15
15
|
|
16
16
|
attr_reader :lims_api
|
17
17
|
|
18
|
+
def initialize(lims_api)
|
19
|
+
@lims_api = lims_api
|
20
|
+
end
|
21
|
+
|
18
22
|
def push_orders(batch_size: 100)
|
19
23
|
loop do
|
20
24
|
logger.info('Fetching new orders...')
|
@@ -40,15 +44,17 @@ module Lab
|
|
40
44
|
def push_order(order)
|
41
45
|
logger.info("Pushing order ##{order.order_id}")
|
42
46
|
|
43
|
-
order_dto =
|
47
|
+
order_dto = OrderSerializer.serialize_order(order)
|
44
48
|
mapping = LimsOrderMapping.find_by(order_id: order.order_id)
|
45
49
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
50
|
+
ActiveRecord::Base.transaction do
|
51
|
+
if mapping
|
52
|
+
lims_api.update_order(mapping.lims_id, order_dto)
|
53
|
+
mapping.update(pushed_at: Time.now)
|
54
|
+
else
|
55
|
+
order_dto = lims_api.create_order(order_dto)
|
56
|
+
LimsOrderMapping.create!(order: order, lims_id: order_dto['_id'], pushed_at: Time.now)
|
57
|
+
end
|
52
58
|
end
|
53
59
|
|
54
60
|
order_dto
|
@@ -57,64 +63,247 @@ module Lab
|
|
57
63
|
##
|
58
64
|
# Pulls orders from the LIMS queue and writes them to the local database
|
59
65
|
def pull_orders
|
66
|
+
logger.info("Retrieving LIMS orders starting from #{last_seq}")
|
60
67
|
lims_api.consume_orders(from: last_seq) do |order_dto, context|
|
61
|
-
logger.debug(
|
68
|
+
logger.debug("Retrieved order ##{order_dto[:tracking_number]}: #{order_dto}")
|
62
69
|
|
63
70
|
patient = find_patient_by_nhid(order_dto[:patient][:id])
|
64
|
-
|
65
71
|
unless patient
|
66
|
-
logger.debug(
|
67
|
-
|
72
|
+
logger.debug("Discarding order: Non local patient ##{order_dto[:patient][:id]} on order ##{order_dto[:tracking_number]}")
|
73
|
+
next [:rejected, "Patient NPID, '#{order_dto[:patient][:id]}', didn't match any local NPIDs"]
|
74
|
+
end
|
75
|
+
|
76
|
+
diff = match_patient_demographics(patient, order_dto['patient'])
|
77
|
+
if diff.empty?
|
78
|
+
save_order(patient, order_dto)
|
79
|
+
else
|
80
|
+
save_failed_import(order_dto, 'Demographics not matching', diff)
|
68
81
|
end
|
69
82
|
|
70
|
-
|
83
|
+
[:accepted, "Patient NPID, '#{order_dto[:patient][:id]}', matched"]
|
84
|
+
rescue DuplicateNHID
|
85
|
+
logger.warn("Failed to import order due to duplicate patient NHID: #{order_dto[:patient][:id]}")
|
86
|
+
save_failed_import(order_dto, "Duplicate local patient NHID: #{order_dto[:patient][:id]}")
|
87
|
+
rescue MissingAccessionNumber
|
88
|
+
logger.warn("Failed to import order due to missing accession number: #{order_dto[:_id]}")
|
89
|
+
save_failed_import(order_dto, 'Order missing tracking number')
|
90
|
+
rescue LimsException => e
|
91
|
+
logger.warn("Failed to import order due to #{e.class} - #{e.message}")
|
92
|
+
save_failed_import(order_dto, e.message)
|
93
|
+
ensure
|
71
94
|
update_last_seq(context.last_seq)
|
72
95
|
end
|
73
96
|
end
|
74
97
|
|
98
|
+
protected
|
99
|
+
|
100
|
+
def last_seq
|
101
|
+
File.open(last_seq_path, File::RDONLY | File::CREAT, 0o644) do |fin|
|
102
|
+
data = fin.read&.strip
|
103
|
+
return nil if data.blank?
|
104
|
+
|
105
|
+
return data
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def update_last_seq(last_seq)
|
110
|
+
File.open(last_seq_path, File::WRONLY | File::CREAT, 0o644) do |fout|
|
111
|
+
fout.flock(File::LOCK_EX)
|
112
|
+
|
113
|
+
fout.write(last_seq.to_s)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
75
117
|
private
|
76
118
|
|
77
119
|
def find_patient_by_nhid(nhid)
|
78
|
-
national_id_type = PatientIdentifierType.where(name: 'National id')
|
79
|
-
|
80
|
-
|
120
|
+
national_id_type = PatientIdentifierType.where(name: ['National id', 'Old Identification Number'])
|
121
|
+
identifiers = PatientIdentifier.where(type: national_id_type, identifier: nhid)
|
122
|
+
if identifiers.count.zero?
|
123
|
+
identifiers = PatientIdentifier.unscoped.where(voided: 1, type: national_id_type, identifier: nhid)
|
124
|
+
end
|
125
|
+
return nil if identifiers.count.zero?
|
81
126
|
|
82
|
-
|
127
|
+
patients = Patient.where(patient_id: identifiers.select(:patient_id))
|
128
|
+
.distinct(:patient_id)
|
129
|
+
.all
|
130
|
+
|
131
|
+
if patients.size > 1
|
132
|
+
raise DuplicateNHID, "Duplicate National Health ID: #{nhid}"
|
133
|
+
end
|
83
134
|
|
84
135
|
patients.first
|
85
136
|
end
|
86
137
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
138
|
+
##
|
139
|
+
# Matches a local patient's demographics to a LIMS patient's demographics
|
140
|
+
def match_patient_demographics(local_patient, lims_patient)
|
141
|
+
diff = {}
|
142
|
+
person = Person.find(local_patient.id)
|
143
|
+
person_name = PersonName.find_by_person_id(local_patient.id)
|
144
|
+
|
145
|
+
unless (person.gender.blank? && lims_patient['gender'].blank?)\
|
146
|
+
|| person.gender&.first&.casecmp?(lims_patient['gender']&.first)
|
147
|
+
diff[:gender] = { local: person.gender, lims: lims_patient['gender'] }
|
96
148
|
end
|
97
149
|
|
98
|
-
|
150
|
+
unless names_match?(person_name&.given_name, lims_patient['first_name'])
|
151
|
+
diff[:given_name] = { local: person_name&.given_name, lims: lims_patient['first_name'] }
|
152
|
+
end
|
153
|
+
|
154
|
+
unless names_match?(person_name&.family_name, lims_patient['last_name'])
|
155
|
+
diff[:family_name] = { local: person_name&.family_name, lims: lims_patient['last_name'] }
|
156
|
+
end
|
157
|
+
|
158
|
+
diff
|
159
|
+
end
|
160
|
+
|
161
|
+
def names_match?(name1, name2)
|
162
|
+
name1 = name1&.gsub(/'/, '')&.strip
|
163
|
+
name2 = name2&.gsub(/'/, '')&.strip
|
164
|
+
|
165
|
+
return true if name1.blank? && name2.blank?
|
166
|
+
|
167
|
+
return false if name1.blank? || name2.blank?
|
168
|
+
|
169
|
+
name1.casecmp?(name2)
|
170
|
+
end
|
171
|
+
|
172
|
+
def save_order(patient, order_dto)
|
173
|
+
raise MissingAccessionNumber if order_dto[:tracking_number].blank?
|
174
|
+
|
175
|
+
logger.info("Importing LIMS order ##{order_dto[:tracking_number]}")
|
176
|
+
mapping = LimsOrderMapping.find_by(lims_id: order_dto[:_id])
|
177
|
+
|
178
|
+
ActiveRecord::Base.transaction do
|
179
|
+
if mapping
|
180
|
+
order = update_order(patient, mapping.order_id, order_dto)
|
181
|
+
mapping.update(pulled_at: Time.now)
|
182
|
+
else
|
183
|
+
order = create_order(patient, order_dto)
|
184
|
+
LimsOrderMapping.create!(lims_id: order_dto[:_id],
|
185
|
+
order_id: order['id'],
|
186
|
+
pulled_at: Time.now,
|
187
|
+
revision: order_dto['_rev'])
|
188
|
+
end
|
189
|
+
|
190
|
+
order
|
191
|
+
end
|
99
192
|
end
|
100
193
|
|
101
194
|
def create_order(patient, order_dto)
|
102
|
-
|
103
|
-
|
195
|
+
logger.debug("Creating order ##{order_dto['_id']}")
|
196
|
+
order = OrdersService.order_test(order_dto.to_order_service_params(patient_id: patient.patient_id))
|
197
|
+
unless order_dto['test_results'].empty?
|
198
|
+
update_results(order, order_dto['test_results'])
|
199
|
+
end
|
104
200
|
|
105
201
|
order
|
106
202
|
end
|
107
203
|
|
108
|
-
def update_order(
|
109
|
-
|
110
|
-
|
204
|
+
def update_order(patient, order_id, order_dto)
|
205
|
+
logger.debug("Updating order ##{order_dto['_id']}")
|
206
|
+
order = OrdersService.update_order(order_id, order_dto.to_order_service_params(patient_id: patient.patient_id)
|
207
|
+
.merge(force_update: true))
|
208
|
+
unless order_dto['test_results'].empty?
|
209
|
+
update_results(order, order_dto['test_results'])
|
210
|
+
end
|
111
211
|
|
112
212
|
order
|
113
213
|
end
|
114
214
|
|
115
|
-
def update_results(
|
116
|
-
|
117
|
-
|
215
|
+
def update_results(order, lims_results)
|
216
|
+
logger.debug("Updating results for order ##{order[:accession_number]}: #{lims_results}")
|
217
|
+
|
218
|
+
lims_results.each do |test_name, test_results|
|
219
|
+
test = find_test(order['id'], test_name)
|
220
|
+
unless test
|
221
|
+
logger.warn("Couldn't find test, #{test_name}, in order ##{order[:id]}")
|
222
|
+
next
|
223
|
+
end
|
224
|
+
|
225
|
+
measures = test_results['results'].map do |indicator, value|
|
226
|
+
measure = find_measure(order, indicator, value)
|
227
|
+
next nil unless measure
|
228
|
+
|
229
|
+
measure
|
230
|
+
end
|
231
|
+
|
232
|
+
measures = measures.compact
|
233
|
+
|
234
|
+
next if measures.empty?
|
235
|
+
|
236
|
+
creator = format_result_entered_by(test_results['result_entered_by'])
|
237
|
+
|
238
|
+
ResultsService.create_results(test.id, provider_id: User.current.person_id,
|
239
|
+
date: Utils.parse_date(test_results['date_result_entered'], order[:order_date].to_s),
|
240
|
+
comments: "LIMS import: Entered by: #{creator}",
|
241
|
+
measures: measures)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
def find_test(order_id, test_name)
|
246
|
+
test_name = Utils.translate_test_name(test_name)
|
247
|
+
test_concept = Utils.find_concept_by_name(test_name)
|
248
|
+
raise "Unknown test name, #{test_name}!" unless test_concept
|
249
|
+
|
250
|
+
LabTest.find_by(order_id: order_id, value_coded: test_concept.concept_id)
|
251
|
+
end
|
252
|
+
|
253
|
+
def find_measure(_order, indicator_name, value)
|
254
|
+
indicator = Utils.find_concept_by_name(indicator_name)
|
255
|
+
unless indicator
|
256
|
+
logger.warn("Result indicator #{indicator_name} not found in concepts list")
|
257
|
+
return nil
|
258
|
+
end
|
259
|
+
|
260
|
+
value_modifier, value, value_type = parse_lims_result_value(value)
|
261
|
+
return nil if value.blank?
|
262
|
+
|
263
|
+
ActiveSupport::HashWithIndifferentAccess.new(
|
264
|
+
indicator: { concept_id: indicator.concept_id },
|
265
|
+
value_type: value_type,
|
266
|
+
value: value_type == 'numeric' ? value.to_f : value,
|
267
|
+
value_modifier: value_modifier
|
268
|
+
)
|
269
|
+
end
|
270
|
+
|
271
|
+
def parse_lims_result_value(value)
|
272
|
+
value = value['result_value']
|
273
|
+
return nil, nil, nil if value.blank?
|
274
|
+
|
275
|
+
match = value&.match(/^(>|=|<|<=|>=)(.*)$/)
|
276
|
+
return nil, value, guess_result_datatype(value) unless match
|
277
|
+
|
278
|
+
[match[1], match[2], guess_result_datatype(match[2])]
|
279
|
+
end
|
280
|
+
|
281
|
+
def guess_result_datatype(result)
|
282
|
+
return 'numeric' if result.match?(/^[+-]?(\d+(\.\d+)|\.\d+)?$/)
|
283
|
+
|
284
|
+
'text'
|
285
|
+
end
|
286
|
+
|
287
|
+
def format_result_entered_by(result_entered_by)
|
288
|
+
first_name = result_entered_by['first_name']
|
289
|
+
last_name = result_entered_by['last_name']
|
290
|
+
phone_number = result_entered_by['phone_number']
|
291
|
+
id = result_entered_by['id'] # Looks like a user_id of some sort
|
292
|
+
|
293
|
+
"#{id}:#{first_name} #{last_name}:#{phone_number}"
|
294
|
+
end
|
295
|
+
|
296
|
+
def save_failed_import(order_dto, reason, diff = nil)
|
297
|
+
logger.info("Failed to import LIMS order ##{order_dto[:tracking_number]} due to '#{reason}'")
|
298
|
+
LimsFailedImport.create!(lims_id: order_dto[:_id],
|
299
|
+
tracking_number: order_dto[:tracking_number],
|
300
|
+
patient_nhid: order_dto[:patient][:id],
|
301
|
+
reason: reason,
|
302
|
+
diff: diff&.to_json)
|
303
|
+
end
|
304
|
+
|
305
|
+
def last_seq_path
|
306
|
+
Rails.root.join('log/lims/last-seq.dat')
|
118
307
|
end
|
119
308
|
end
|
120
309
|
end
|