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,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lab
|
|
4
|
+
module Lims
|
|
5
|
+
##
|
|
6
|
+
# Pulls orders from a Lims API object and saves them to the local database.
|
|
7
|
+
class PullWorker
|
|
8
|
+
attr_reader :lims_api
|
|
9
|
+
|
|
10
|
+
include Utils # for logger
|
|
11
|
+
|
|
12
|
+
LIMS_LOG_PATH = Rails.root.join('log', 'lims')
|
|
13
|
+
|
|
14
|
+
def initialize(lims_api)
|
|
15
|
+
@lims_api = lims_api
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# Pulls orders from the LIMS queue and writes them to the local database
|
|
20
|
+
def pull_orders(batch_size: 10_000, **kwargs)
|
|
21
|
+
logger.info("Retrieving LIMS orders starting from #{last_seq}")
|
|
22
|
+
|
|
23
|
+
lims_api.consume_orders(from: last_seq, limit: batch_size, **kwargs) do |order_dto, context|
|
|
24
|
+
logger.debug("Retrieved order ##{order_dto[:tracking_number]}: #{order_dto}")
|
|
25
|
+
|
|
26
|
+
patient = find_patient_by_nhid(order_dto[:patient][:id])
|
|
27
|
+
unless patient
|
|
28
|
+
logger.debug("Discarding order: Non local patient ##{order_dto[:patient][:id]} on order ##{order_dto[:tracking_number]}")
|
|
29
|
+
order_rejected(order_dto, "Patient NPID, '#{order_dto[:patient][:id]}', didn't match any local NPIDs")
|
|
30
|
+
next
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if order_dto[:tests].empty?
|
|
34
|
+
logger.debug("Discarding order: Missing tests on order ##{order_dto[:tracking_number]}")
|
|
35
|
+
order_rejected(order_dto, 'Order is missing tests')
|
|
36
|
+
next
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
diff = match_patient_demographics(patient, order_dto['patient'])
|
|
40
|
+
if diff.empty?
|
|
41
|
+
save_order(patient, order_dto)
|
|
42
|
+
order_saved(order_dto)
|
|
43
|
+
else
|
|
44
|
+
save_failed_import(order_dto, 'Demographics not matching', diff)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
update_last_seq(context.current_seq)
|
|
48
|
+
rescue Lab::Lims::DuplicateNHID
|
|
49
|
+
logger.warn("Failed to import order due to duplicate patient NHID: #{order_dto[:patient][:id]}")
|
|
50
|
+
save_failed_import(order_dto, "Duplicate local patient NHID: #{order_dto[:patient][:id]}")
|
|
51
|
+
rescue MissingAccessionNumber
|
|
52
|
+
logger.warn("Failed to import order due to missing accession number: #{order_dto[:_id]}")
|
|
53
|
+
save_failed_import(order_dto, 'Order missing tracking number')
|
|
54
|
+
rescue LimsException => e
|
|
55
|
+
logger.warn("Failed to import order due to #{e.class} - #{e.message}")
|
|
56
|
+
save_failed_import(order_dto, e.message)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
protected
|
|
61
|
+
|
|
62
|
+
def order_saved(order_dto); end
|
|
63
|
+
|
|
64
|
+
def order_rejected(order_dto, message); end
|
|
65
|
+
|
|
66
|
+
def last_seq
|
|
67
|
+
File.open(last_seq_path, File::RDONLY | File::CREAT, 0o644) do |fin|
|
|
68
|
+
data = fin.read&.strip
|
|
69
|
+
return nil if data.blank?
|
|
70
|
+
|
|
71
|
+
return data
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def update_last_seq(last_seq)
|
|
76
|
+
File.open(last_seq_path, File::WRONLY | File::CREAT, 0o644) do |fout|
|
|
77
|
+
fout.flock(File::LOCK_EX)
|
|
78
|
+
|
|
79
|
+
fout.write(last_seq.to_s)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def find_patient_by_nhid(nhid)
|
|
86
|
+
national_id_type = PatientIdentifierType.where(name: ['National id', 'Old Identification Number'])
|
|
87
|
+
identifiers = PatientIdentifier.where(type: national_id_type, identifier: nhid)
|
|
88
|
+
.joins('INNER JOIN person ON person.person_id = patient_identifier.patient_id AND person.voided = 0')
|
|
89
|
+
if identifiers.count.zero?
|
|
90
|
+
identifiers = PatientIdentifier.unscoped
|
|
91
|
+
.where(voided: 1, type: national_id_type, identifier: nhid)
|
|
92
|
+
.joins('INNER JOIN person ON person.person_id = patient_identifier.patient_id AND person.voided = 0')
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Joining to person above to ensure that the person is not voided,
|
|
96
|
+
# it was noted at one site that there were some people that were voided
|
|
97
|
+
# upon merging but the patient and patient_identifier was not voided
|
|
98
|
+
|
|
99
|
+
return nil if identifiers.count.zero?
|
|
100
|
+
|
|
101
|
+
patients = Patient.where(patient_id: identifiers.select(:patient_id))
|
|
102
|
+
.distinct(:patient_id)
|
|
103
|
+
.all
|
|
104
|
+
|
|
105
|
+
raise Lab::Lims::DuplicateNHID, "Duplicate National Health ID: #{nhid}" if patients.size > 1
|
|
106
|
+
|
|
107
|
+
patients.first
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
##
|
|
111
|
+
# Matches a local patient's demographics to a LIMS patient's demographics
|
|
112
|
+
def match_patient_demographics(local_patient, lims_patient)
|
|
113
|
+
diff = {}
|
|
114
|
+
person = Person.find(local_patient.id)
|
|
115
|
+
person_name = PersonName.find_by_person_id(local_patient.id)
|
|
116
|
+
|
|
117
|
+
unless (person.gender.blank? && lims_patient['gender'].blank?)\
|
|
118
|
+
|| person.gender&.first&.casecmp?(lims_patient['gender']&.first)
|
|
119
|
+
diff[:gender] = { local: person.gender, lims: lims_patient['gender'] }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
unless names_match?(person_name&.given_name, lims_patient['first_name'])
|
|
123
|
+
diff[:given_name] = { local: person_name&.given_name, lims: lims_patient['first_name'] }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
unless names_match?(person_name&.family_name, lims_patient['last_name'])
|
|
127
|
+
diff[:family_name] = { local: person_name&.family_name, lims: lims_patient['last_name'] }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
diff
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def names_match?(name1, name2)
|
|
134
|
+
name1 = name1&.gsub(/'/, '')&.strip
|
|
135
|
+
name2 = name2&.gsub(/'/, '')&.strip
|
|
136
|
+
|
|
137
|
+
return true if name1.blank? && name2.blank?
|
|
138
|
+
|
|
139
|
+
return false if name1.blank? || name2.blank?
|
|
140
|
+
|
|
141
|
+
name1.casecmp?(name2)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def save_order(patient, order_dto)
|
|
145
|
+
raise MissingAccessionNumber if order_dto[:tracking_number].blank?
|
|
146
|
+
|
|
147
|
+
logger.info("Importing LIMS order ##{order_dto[:tracking_number]}")
|
|
148
|
+
mapping = find_order_mapping_by_lims_id(order_dto[:_id])
|
|
149
|
+
|
|
150
|
+
ActiveRecord::Base.transaction do
|
|
151
|
+
if mapping
|
|
152
|
+
order = update_order(patient, mapping.order_id, order_dto)
|
|
153
|
+
mapping.update(pulled_at: Time.now)
|
|
154
|
+
else
|
|
155
|
+
order = create_order(patient, order_dto)
|
|
156
|
+
mapping = LimsOrderMapping.create(lims_id: order_dto[:_id],
|
|
157
|
+
order_id: order['id'],
|
|
158
|
+
pulled_at: Time.now,
|
|
159
|
+
revision: order_dto['_rev'])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
order
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def create_order(patient, order_dto)
|
|
167
|
+
logger.debug("Creating order ##{order_dto['_id']}")
|
|
168
|
+
order = OrdersService.order_test(order_dto.to_order_service_params(patient_id: patient.patient_id))
|
|
169
|
+
update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
|
|
170
|
+
|
|
171
|
+
order
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def update_order(patient, order_id, order_dto)
|
|
175
|
+
logger.debug("Updating order ##{order_dto['_id']}")
|
|
176
|
+
order = OrdersService.update_order(order_id, order_dto.to_order_service_params(patient_id: patient.patient_id)
|
|
177
|
+
.merge(force_update: 'true'))
|
|
178
|
+
update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
|
|
179
|
+
|
|
180
|
+
order
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def update_results(order, lims_results)
|
|
184
|
+
logger.debug("Updating results for order ##{order[:accession_number]}: #{lims_results}")
|
|
185
|
+
|
|
186
|
+
lims_results.each do |test_name, test_results|
|
|
187
|
+
test = find_test(order['id'], test_name)
|
|
188
|
+
unless test
|
|
189
|
+
logger.warn("Couldn't find test, #{test_name}, in order ##{order[:id]}")
|
|
190
|
+
next
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
next if test.result || test_results['results'].blank?
|
|
194
|
+
|
|
195
|
+
measures = test_results['results'].map do |indicator, value|
|
|
196
|
+
measure = find_measure(order, indicator, value)
|
|
197
|
+
next nil unless measure
|
|
198
|
+
|
|
199
|
+
measure
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
measures = measures.compact
|
|
203
|
+
next if measures.empty?
|
|
204
|
+
|
|
205
|
+
creator = format_result_entered_by(test_results['result_entered_by'])
|
|
206
|
+
|
|
207
|
+
ResultsService.create_results(test.id, { provider_id: User.current.person_id,
|
|
208
|
+
date: Utils.parse_date(test_results['date_result_entered'], order[:order_date].to_s),
|
|
209
|
+
comments: "LIMS import: Entered by: #{creator}",
|
|
210
|
+
measures: measures } )
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def find_test(order_id, test_name)
|
|
215
|
+
test_name = Utils.translate_test_name(test_name)
|
|
216
|
+
test_concept = Utils.find_concept_by_name(test_name)
|
|
217
|
+
raise "Unknown test name, #{test_name}!" unless test_concept
|
|
218
|
+
|
|
219
|
+
LabTest.find_by(order_id: order_id, value_coded: test_concept.concept_id)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def find_measure(_order, indicator_name, value)
|
|
223
|
+
indicator = Utils.find_concept_by_name(indicator_name)
|
|
224
|
+
unless indicator
|
|
225
|
+
logger.warn("Result indicator #{indicator_name} not found in concepts list")
|
|
226
|
+
return nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
value_modifier, value, value_type = parse_lims_result_value(value)
|
|
230
|
+
return nil if value.blank?
|
|
231
|
+
|
|
232
|
+
ActiveSupport::HashWithIndifferentAccess.new(
|
|
233
|
+
indicator: { concept_id: indicator.concept_id },
|
|
234
|
+
value_type: value_type,
|
|
235
|
+
value: value_type == 'numeric' ? value.to_f : value,
|
|
236
|
+
value_modifier: value_modifier.blank? ? '=' : value_modifier
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def parse_lims_result_value(value)
|
|
241
|
+
value = value['result_value']&.strip
|
|
242
|
+
return nil, nil, nil if value.blank?
|
|
243
|
+
|
|
244
|
+
match = value&.match(/^(>|=|<|<=|>=)(.*)$/)
|
|
245
|
+
return nil, value, guess_result_datatype(value) unless match
|
|
246
|
+
|
|
247
|
+
[match[1], match[2].strip, guess_result_datatype(match[2])]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def guess_result_datatype(result)
|
|
251
|
+
return 'numeric' if result.strip.match?(/^[+-]?((\d+(\.\d+)?)|\.\d+)$/)
|
|
252
|
+
|
|
253
|
+
'text'
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def format_result_entered_by(result_entered_by)
|
|
257
|
+
first_name = result_entered_by['first_name']
|
|
258
|
+
last_name = result_entered_by['last_name']
|
|
259
|
+
phone_number = result_entered_by['phone_number']
|
|
260
|
+
id = result_entered_by['id'] # Looks like a user_id of some sort
|
|
261
|
+
|
|
262
|
+
"#{id}:#{first_name} #{last_name}:#{phone_number}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def save_failed_import(order_dto, reason, diff = nil)
|
|
266
|
+
logger.info("Failed to import LIMS order ##{order_dto[:tracking_number]} due to '#{reason}'")
|
|
267
|
+
LimsFailedImport.create!(lims_id: order_dto[:_id],
|
|
268
|
+
tracking_number: order_dto[:tracking_number],
|
|
269
|
+
patient_nhid: order_dto[:patient][:id],
|
|
270
|
+
reason: reason,
|
|
271
|
+
diff: diff&.to_json)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def last_seq_path
|
|
275
|
+
LIMS_LOG_PATH.join('last_seq.dat')
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def find_order_mapping_by_lims_id(lims_id)
|
|
279
|
+
mapping = Lab::LimsOrderMapping.find_by(lims_id: lims_id)
|
|
280
|
+
return nil unless mapping
|
|
281
|
+
|
|
282
|
+
return mapping if Lab::LabOrder.where(order_id: mapping.order_id).exists?
|
|
283
|
+
|
|
284
|
+
mapping.destroy
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lab
|
|
4
|
+
module Lims
|
|
5
|
+
##
|
|
6
|
+
# Pushes all local orders to a LIMS Api object.
|
|
7
|
+
class PushWorker
|
|
8
|
+
attr_reader :lims_api
|
|
9
|
+
|
|
10
|
+
include Utils # for logger
|
|
11
|
+
|
|
12
|
+
SECONDS_TO_WAIT_FOR_ORDERS = 30
|
|
13
|
+
|
|
14
|
+
def initialize(lims_api)
|
|
15
|
+
@lims_api = lims_api
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def push_orders(batch_size: 1000, wait: false)
|
|
19
|
+
loop do
|
|
20
|
+
logger.info('Looking for new orders to push to LIMS...')
|
|
21
|
+
orders = orders_pending_sync(batch_size).all
|
|
22
|
+
|
|
23
|
+
logger.debug("Found #{orders.size} orders...")
|
|
24
|
+
orders.each do |order|
|
|
25
|
+
push_order(order)
|
|
26
|
+
rescue GatewayError => e
|
|
27
|
+
logger.error("Failed to push order ##{order.accession_number}: #{e.class} - #{e.message}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
break unless wait
|
|
31
|
+
|
|
32
|
+
logger.info('Waiting for orders...')
|
|
33
|
+
sleep(Lab::Lims::Config.updates_poll_frequency)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def push_order_by_id(order_id)
|
|
38
|
+
order = Lab::LabOrder.joins(order_type: { name: 'Lab' })
|
|
39
|
+
.unscoped
|
|
40
|
+
.find(order_id)
|
|
41
|
+
push_order(order)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# Pushes given order to LIMS queue
|
|
46
|
+
def push_order(order)
|
|
47
|
+
logger.info("Pushing order ##{order.order_id}")
|
|
48
|
+
|
|
49
|
+
order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
|
|
50
|
+
mapping = Lab::LimsOrderMapping.find_by(order_id: order.order_id)
|
|
51
|
+
|
|
52
|
+
ActiveRecord::Base.transaction do
|
|
53
|
+
if mapping && !order.voided.zero?
|
|
54
|
+
Rails.logger.info("Deleting order ##{order_dto[:accession_number]} from LIMS")
|
|
55
|
+
lims_api.delete_order(mapping.lims_id, order_dto)
|
|
56
|
+
mapping.destroy
|
|
57
|
+
elsif mapping
|
|
58
|
+
Rails.logger.info("Updating order ##{order_dto[:accession_number]} in LIMS")
|
|
59
|
+
lims_api.update_order(mapping.lims_id, order_dto)
|
|
60
|
+
if order_dto['test_results'].nil? || order_dto['test_results'].empty?
|
|
61
|
+
mapping.update(pushed_at: Time.now)
|
|
62
|
+
else
|
|
63
|
+
mapping.update(pushed_at: Time.now, result_push_status: true)
|
|
64
|
+
end
|
|
65
|
+
elsif order_dto[:_id] && Lab::LimsOrderMapping.where(lims_id: order_dto[:_id]).exists?
|
|
66
|
+
# HACK: v1.1.7 had a bug where duplicates of recently created orders where being created by
|
|
67
|
+
# the pull worker. This here detects those duplicates and voids them.
|
|
68
|
+
Rails.logger.warn("Duplicate accession number found: #{order_dto[:_id]}, skipping order...")
|
|
69
|
+
fix_duplicates!(order)
|
|
70
|
+
else
|
|
71
|
+
Rails.logger.info("Creating order ##{order_dto[:accession_number]} in LIMS")
|
|
72
|
+
update = lims_api.create_order(order_dto)
|
|
73
|
+
Lab::LimsOrderMapping.create!(order: order, lims_id: update['id'], revision: update['rev'],
|
|
74
|
+
pushed_at: Time.now, result_push_status: false)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
order_dto
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def orders_pending_sync(batch_size)
|
|
84
|
+
return new_orders.limit(batch_size) if new_orders.exists?
|
|
85
|
+
|
|
86
|
+
return voided_orders.limit(batch_size) if voided_orders.exists?
|
|
87
|
+
|
|
88
|
+
updated_orders.limit(batch_size)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def new_orders
|
|
92
|
+
Rails.logger.debug('Looking for new orders that need to be created in LIMS...')
|
|
93
|
+
Lab::LabOrder.where.not(order_id: Lab::LimsOrderMapping.all.select(:order_id))
|
|
94
|
+
.order(date_created: :desc)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def updated_orders
|
|
98
|
+
Rails.logger.debug('Looking for recently updated orders that need to be pushed to LIMS...')
|
|
99
|
+
last_updated = Lab::LimsOrderMapping.select('MAX(updated_at) AS last_updated')
|
|
100
|
+
.first
|
|
101
|
+
.last_updated
|
|
102
|
+
|
|
103
|
+
Lab::LabOrder.left_joins(:results)
|
|
104
|
+
.joins(:mapping)
|
|
105
|
+
.where('orders.discontinued_date > :last_updated
|
|
106
|
+
OR obs.date_created > orders.date_created AND lab_lims_order_mappings.result_push_status = 0',
|
|
107
|
+
last_updated: last_updated)
|
|
108
|
+
.group('orders.order_id')
|
|
109
|
+
.order(discontinued_date: :desc, date_created: :desc)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def voided_orders
|
|
113
|
+
Rails.logger.debug('Looking for voided orders that are being tracked by LIMS...')
|
|
114
|
+
Lab::LabOrder.unscoped
|
|
115
|
+
.where(order_type: OrderType.where(name: Lab::Metadata::ORDER_TYPE_NAME),
|
|
116
|
+
order_id: Lab::LimsOrderMapping.all.select(:order_id),
|
|
117
|
+
voided: 1)
|
|
118
|
+
.order(date_voided: :desc)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
##
|
|
122
|
+
# HACK: Checks for duplicates previously created by version 1.1.7 pull worker bug due to this proving orders
|
|
123
|
+
# that have not been pushed to LIMS as orders awaiting updates.
|
|
124
|
+
def fix_duplicates!(order)
|
|
125
|
+
return order.void('Duplicate created by bug in HIS-EMR-API-Lab v1.1.7') unless order_has_specimen?(order)
|
|
126
|
+
|
|
127
|
+
duplicate_order = Lab::LabOrder.where(accession_number: order.accession_number)
|
|
128
|
+
.where.not(order_id: order.order_id)
|
|
129
|
+
.first
|
|
130
|
+
return unless duplicate_order
|
|
131
|
+
|
|
132
|
+
if !order_has_results?(order) && (order_has_results?(duplicate_order) || order_has_specimen?(duplicate_order))
|
|
133
|
+
order.void('DUplicate created by bug in HIS-EMR-API-Lab v1.1.7')
|
|
134
|
+
else
|
|
135
|
+
duplicate_order.void('Duplicate created by bug in HIS-EMR-API-Lab v1.1.7')
|
|
136
|
+
Lab::LimsOrderMapping.find_by_lims_id(order.accession_number)&.destroy
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def order_has_results?(order)
|
|
141
|
+
order.results.exists?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def order_has_specimen?(order)
|
|
145
|
+
order.concept_id == ConceptName.find_by_name!('Unknown').concept_id
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi/util'
|
|
4
|
+
|
|
5
|
+
module Lab
|
|
6
|
+
module Lims
|
|
7
|
+
##
|
|
8
|
+
# Various helper methods for modules in the Lims namespaces...
|
|
9
|
+
module Utils
|
|
10
|
+
LIMS_LOG_PATH = Rails.root.join('log', 'lims')
|
|
11
|
+
FileUtils.mkdir_p(LIMS_LOG_PATH) unless File.exist?(LIMS_LOG_PATH)
|
|
12
|
+
|
|
13
|
+
def logger
|
|
14
|
+
Rails.logger
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
TEST_NAME_MAPPINGS = {
|
|
18
|
+
# For some weird reason(s) some tests have multiple names in LIMS,
|
|
19
|
+
# this is used to sanitize those names.
|
|
20
|
+
'hiv_viral_load' => 'HIV Viral Load',
|
|
21
|
+
'viral laod' => 'HIV Viral Load',
|
|
22
|
+
'viral load' => 'HIV Viral Load',
|
|
23
|
+
'i/ink' => 'India ink',
|
|
24
|
+
'indian ink' => 'India ink'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def self.translate_test_name(test_name)
|
|
28
|
+
TEST_NAME_MAPPINGS.fetch(test_name.downcase, test_name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.structify(object)
|
|
32
|
+
if object.is_a?(Hash)
|
|
33
|
+
object.each_with_object(OpenStruct.new) do |kv_pair, struct|
|
|
34
|
+
key, value = kv_pair
|
|
35
|
+
|
|
36
|
+
struct[key] = structify(value)
|
|
37
|
+
end
|
|
38
|
+
elsif object.respond_to?(:map)
|
|
39
|
+
object.map { |item| structify(item) }
|
|
40
|
+
else
|
|
41
|
+
object
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.lab_user
|
|
46
|
+
user = User.find_by_username('lab_daemon')
|
|
47
|
+
return user if user
|
|
48
|
+
|
|
49
|
+
god_user = User.first
|
|
50
|
+
|
|
51
|
+
person = Person.create!(creator: god_user.user_id)
|
|
52
|
+
PersonName.create!(person: person, given_name: 'Lab', family_name: 'Daemon', creator: god_user.user_id)
|
|
53
|
+
|
|
54
|
+
User.create!(username: 'lab_daemon', person: person, creator: god_user.user_id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.parse_date(str_date, fallback_date = nil)
|
|
58
|
+
str_date = str_date&.to_s
|
|
59
|
+
|
|
60
|
+
if str_date.blank? && fallback_date.blank?
|
|
61
|
+
raise "Can't parse blank date"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return parse_date(fallback_date) if str_date.blank?
|
|
65
|
+
|
|
66
|
+
str_date = str_date.gsub(/^00/, '20').gsub(/^180/, '20')
|
|
67
|
+
|
|
68
|
+
case str_date
|
|
69
|
+
when /\d{4}-\d{2}-\d{2}/
|
|
70
|
+
str_date
|
|
71
|
+
when /\d{2}-\d{2}-\d{2}/
|
|
72
|
+
Date.strptime(str_date, '%d-%m-%Y').strftime('%Y-%m-%d')
|
|
73
|
+
when /(\d{4}\d{2}\d{2})\d+/
|
|
74
|
+
Date.strptime(str_date, '%Y%m%d').strftime('%Y-%m-%d')
|
|
75
|
+
when %r{\d{2}/\d{2}/\d{4}}
|
|
76
|
+
str_date.to_date.to_s
|
|
77
|
+
else
|
|
78
|
+
Rails.logger.warn("Invalid date: #{str_date}")
|
|
79
|
+
parse_date(fallback_date)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.find_concept_by_name(name)
|
|
84
|
+
ConceptName.joins(:concept)
|
|
85
|
+
.merge(Concept.all) # Filter out voided
|
|
86
|
+
.where(name: CGI.unescapeHTML(name))
|
|
87
|
+
.first
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger_multiplexor'
|
|
4
|
+
|
|
5
|
+
require_relative './api/couchdb_api'
|
|
6
|
+
|
|
7
|
+
module Lab
|
|
8
|
+
module Lims
|
|
9
|
+
##
|
|
10
|
+
# Pull/Push orders from/to the LIMS queue (Oops meant CouchDB).
|
|
11
|
+
module Worker
|
|
12
|
+
def self.start
|
|
13
|
+
User.current = Utils.lab_user
|
|
14
|
+
|
|
15
|
+
fork(&method(:start_push_worker))
|
|
16
|
+
fork(&method(:start_pull_worker))
|
|
17
|
+
fork(&method(:start_realtime_pull_worker)) if realtime_updates_enabled?
|
|
18
|
+
|
|
19
|
+
Process.waitall
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.start_push_worker
|
|
23
|
+
start_worker('push_worker') do
|
|
24
|
+
worker = PushWorker.new(lims_api)
|
|
25
|
+
|
|
26
|
+
worker.push_orders # (wait: true)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.start_pull_worker
|
|
31
|
+
start_worker('pull_worker') do
|
|
32
|
+
worker = PullWorker.new(lims_api)
|
|
33
|
+
|
|
34
|
+
worker.pull_orders
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.start_realtime_pull_worker
|
|
39
|
+
start_worker('realtime_pull_worker') do
|
|
40
|
+
worker = PullWorker.new(Lims::Api::WsApi.new(Lab::Lims::Config.updates_socket))
|
|
41
|
+
|
|
42
|
+
worker.pull_orders
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
LOG_FILES_TO_KEEP = 5
|
|
47
|
+
LOG_FILE_SIZE = 500.megabytes
|
|
48
|
+
|
|
49
|
+
def self.start_worker(worker_name)
|
|
50
|
+
Rails.logger = LoggerMultiplexor.new(file_logger(worker_name), $stdout)
|
|
51
|
+
ActiveRecord::Base.logger = Rails.logger
|
|
52
|
+
Rails.logger.level = :debug
|
|
53
|
+
|
|
54
|
+
File.open(log_path("#{worker_name}.lock"), File::RDWR | File::CREAT, 0o644) do |fout|
|
|
55
|
+
unless fout.flock(File::LOCK_EX | File::LOCK_NB)
|
|
56
|
+
Rails.logger.warn("Another process already holds lock #{worker_name} (#{fout.read}), exiting...")
|
|
57
|
+
break
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
fout.write("Locked by process ##{Process.pid} under process group ##{Process.ppid} at #{Time.now}")
|
|
61
|
+
fout.flush
|
|
62
|
+
yield
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.file_logger(worker_name)
|
|
67
|
+
Logger.new(log_path("#{worker_name}.log"), LOG_FILES_TO_KEEP, LOG_FILE_SIZE)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.log_path(filename)
|
|
71
|
+
Lab::Lims::Utils::LIMS_LOG_PATH.join(filename)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.realtime_updates_enabled?
|
|
75
|
+
Lims::Config.updates_socket.key?('url')
|
|
76
|
+
rescue Lab::Lims::Config::ConfigNotFound => e
|
|
77
|
+
Rails.logger.warn("Check for realtime updates failed: #{e.message}")
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.lims_api
|
|
82
|
+
Lab::Lims::ApiFactory.create_api
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lab
|
|
4
|
+
module Metadata
|
|
5
|
+
# Concepts
|
|
6
|
+
REASON_FOR_TEST_CONCEPT_NAME = 'Reason for test'
|
|
7
|
+
REQUESTING_CLINICIAN_CONCEPT_NAME = 'Person making request'
|
|
8
|
+
SPECIMEN_TYPE_CONCEPT_NAME = 'Specimen type'
|
|
9
|
+
TARGET_LAB_CONCEPT_NAME = 'Lab'
|
|
10
|
+
TEST_RESULT_CONCEPT_NAME = 'Lab test result'
|
|
11
|
+
TEST_RESULT_INDICATOR_CONCEPT_NAME = 'Lab test result indicator'
|
|
12
|
+
TEST_TYPE_CONCEPT_NAME = 'Test type'
|
|
13
|
+
UNKNOWN_SPECIMEN = 'Unknown'
|
|
14
|
+
|
|
15
|
+
# Encounter
|
|
16
|
+
ENCOUNTER_TYPE_NAME = 'Lab'
|
|
17
|
+
|
|
18
|
+
# Order types
|
|
19
|
+
ORDER_TYPE_NAME = 'Lab'
|
|
20
|
+
|
|
21
|
+
# Programs
|
|
22
|
+
LAB_PROGRAM_NAME = 'Laboratory Program'
|
|
23
|
+
end
|
|
24
|
+
end
|