his_emr_api_lab 1.0.5 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ class PushWorker
6
+ attr_reader :lims_api
7
+
8
+ include Utils # for logger
9
+
10
+ SECONDS_TO_WAIT_FOR_ORDERS = 30
11
+
12
+ def initialize(lims_api)
13
+ @lims_api = lims_api
14
+ end
15
+
16
+ def push_orders(batch_size: 1000, wait: false)
17
+ loop do
18
+ logger.info('Looking for new orders to push to LIMS...')
19
+ orders = orders_pending_sync(batch_size).all
20
+ orders.each { |order| push_order(order) }
21
+
22
+ # Doing this after .each above to stop ActiveRecord from executing
23
+ # an extra request to the database (ActiveRecord's lazy evaluation
24
+ # sometimes leads to unnecessary database hits for checking counts).
25
+ if orders.empty? && !wait
26
+ logger.info('Finished processing orders; exiting...')
27
+ break
28
+ end
29
+
30
+ sleep(Lab::Lims::Config.updates_poll_frequency)
31
+ end
32
+ end
33
+
34
+ def push_order_by_id(order_id)
35
+ order = Lab::LabOrder.unscoped.find(order_id)
36
+ push_order(order)
37
+ end
38
+
39
+ ##
40
+ # Pushes given order to LIMS queue
41
+ def push_order(order)
42
+ logger.info("Pushing order ##{order.order_id}")
43
+
44
+ order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
45
+ mapping = Lab::LimsOrderMapping.find_by(order_id: order.order_id)
46
+
47
+ ActiveRecord::Base.transaction do
48
+ if mapping && !order.voided.zero?
49
+ Rails.logger.info("Deleting order ##{order_dto['accession_number']} from LIMS")
50
+ lims_api.delete_order(mapping.lims_id, order_dto)
51
+ mapping.destroy
52
+ elsif mapping
53
+ Rails.logger.info("Updating order ##{order_dto['accession_number']} in LIMS")
54
+ lims_api.update_order(mapping.lims_id, order_dto)
55
+ mapping.update(pushed_at: Time.now)
56
+ else
57
+ Rails.logger.info("Creating order ##{order_dto['accession_number']} in LIMS")
58
+ update = lims_api.create_order(order_dto)
59
+ Lab::LimsOrderMapping.create!(order: order, lims_id: update['id'], revision: update['rev'], pushed_at: Time.now)
60
+ end
61
+ end
62
+
63
+ order_dto
64
+ end
65
+
66
+ private
67
+
68
+ def orders_pending_sync(batch_size)
69
+ return new_orders.limit(batch_size) if new_orders.exists?
70
+
71
+ return voided_orders.limit(batch_size) if voided_orders.exists?
72
+
73
+ updated_orders.limit(batch_size)
74
+ end
75
+
76
+ def new_orders
77
+ Rails.logger.debug('Looking for new orders that need to be created in LIMS...')
78
+ Lab::LabOrder.where.not(order_id: Lab::LimsOrderMapping.all.select(:order_id))
79
+ end
80
+
81
+ def updated_orders
82
+ Rails.logger.debug('Looking for recently updated orders that need to be pushed to LIMS...')
83
+ last_updated = Lab::LimsOrderMapping.select('MAX(updated_at) AS last_updated')
84
+ .first
85
+ .last_updated
86
+
87
+ Lab::LabOrder.left_joins(:results)
88
+ .where('orders.discontinued_date > :last_updated
89
+ OR obs.date_created > :last_updated',
90
+ last_updated: last_updated)
91
+ .group('orders.order_id')
92
+ end
93
+
94
+ def voided_orders
95
+ Rails.logger.debug('Looking for voided orders that are being tracked by LIMS...')
96
+ Lab::LabOrder.unscoped
97
+ .where(order_type: OrderType.where(name: Lab::Metadata::ORDER_TYPE_NAME),
98
+ order_id: Lab::LimsOrderMapping.all.select(:order_id),
99
+ voided: 1)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -7,6 +7,9 @@ module Lab
7
7
  ##
8
8
  # Various helper methods for modules in the Lims namespaces...
9
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
+
10
13
  def logger
11
14
  Rails.logger
12
15
  end
@@ -54,7 +57,9 @@ module Lab
54
57
  def self.parse_date(str_date, fallback_date = nil)
55
58
  str_date = str_date&.to_s
56
59
 
57
- raise "Can't parse blank date" if str_date.blank? && fallback_date.blank?
60
+ if str_date.blank? && fallback_date.blank?
61
+ raise "Can't parse blank date"
62
+ end
58
63
 
59
64
  return parse_date(fallback_date) if str_date.blank?
60
65
 
@@ -1,352 +1,75 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi/util'
4
-
5
- require_relative './api/couchdb_api'
6
- require_relative './exceptions'
7
- require_relative './order_serializer'
8
- require_relative './utils'
3
+ require 'logger_multiplexor'
9
4
 
10
5
  module Lab
11
6
  module Lims
12
- LIMS_LOG_PATH = Rails.root.join('log/lims')
13
- Dir.mkdir(LIMS_LOG_PATH) unless File.exist?(LIMS_LOG_PATH)
14
-
15
7
  ##
16
8
  # Pull/Push orders from/to the LIMS queue (Oops meant CouchDB).
17
- class Worker
18
- include Utils
19
-
20
- attr_reader :lims_api
21
-
9
+ module Worker
22
10
  def self.start
23
- File.open(LIMS_LOG_PATH.join('worker.lock'), File::WRONLY | File::CREAT, 0o644) do |fout|
24
- fout.flock(File::LOCK_EX)
25
-
26
- User.current = Utils.lab_user
27
-
28
- fout.write("Worker ##{Process.pid} started at #{Time.now}")
29
- worker = new(Api::CouchDbApi.new)
30
- worker.pull_orders
31
- # TODO: Verify that names being pushed to LIMS are of the correct format (ie matching
32
- # LIMS naming conventions). Enable pushing when that is done
33
- worker.push_orders
34
- end
35
- end
36
-
37
- def initialize(lims_api)
38
- @lims_api = lims_api
39
- end
40
-
41
- def push_orders(batch_size: 100)
42
- loop do
43
- logger.info('Fetching new orders...')
44
- orders = LabOrder.where.not(order_id: LimsOrderMapping.all.select(:order_id))
45
- .limit(batch_size)
46
-
47
- if orders.empty?
48
- logger.info('No new orders available; exiting...')
49
- break
50
- end
11
+ User.current = Utils.lab_user
51
12
 
52
- orders.each { |order| push_order(order) }
53
- end
54
- end
13
+ fork(&method(:start_push_worker))
14
+ fork(&method(:start_pull_worker))
15
+ fork(&method(:start_realtime_pull_worker)) if realtime_updates_enabled?
55
16
 
56
- def push_order_by_id(order_id)
57
- order = LabOrder.find(order_id)
58
- push_order(order)
17
+ Process.waitall
59
18
  end
60
19
 
61
- ##
62
- # Pushes given order to LIMS queue
63
- def push_order(order)
64
- logger.info("Pushing order ##{order.order_id}")
20
+ def self.start_push_worker
21
+ start_worker('push_worker') do
22
+ api = Lims::Api::RestApi.new(Lab::Lims::Config.rest_api)
23
+ worker = PushWorker.new(api)
65
24
 
66
- order_dto = OrderSerializer.serialize_order(order)
67
- mapping = LimsOrderMapping.find_by(order_id: order.order_id)
68
-
69
- ActiveRecord::Base.transaction do
70
- if mapping
71
- lims_api.update_order(mapping.lims_id, order_dto)
72
- mapping.update(pushed_at: Time.now)
73
- else
74
- update = lims_api.create_order(order_dto)
75
- LimsOrderMapping.create!(order: order, lims_id: update['id'], revision: update['rev'], pushed_at: Time.now)
76
- end
25
+ worker.push_orders # (wait: true)
77
26
  end
78
-
79
- order_dto
80
- end
81
-
82
- ##
83
- # Pulls orders from the LIMS queue and writes them to the local database
84
- def pull_orders(batch_size: 10_000)
85
- logger.info("Retrieving LIMS orders starting from #{last_seq}")
86
-
87
- lims_api.consume_orders(from: last_seq, limit: batch_size) do |order_dto, context|
88
- logger.debug("Retrieved order ##{order_dto[:tracking_number]}: #{order_dto}")
89
-
90
- patient = find_patient_by_nhid(order_dto[:patient][:id])
91
- unless patient
92
- logger.debug("Discarding order: Non local patient ##{order_dto[:patient][:id]} on order ##{order_dto[:tracking_number]}")
93
- order_rejected(order_dto, "Patient NPID, '#{order_dto[:patient][:id]}', didn't match any local NPIDs")
94
- next
95
- end
96
-
97
- if order_dto[:tests].empty?
98
- logger.debug("Discarding order: Missing tests on order ##{order_dto[:tracking_number]}")
99
- order_rejected(order_dto, 'Order is missing tests')
100
- next
101
- end
102
-
103
- diff = match_patient_demographics(patient, order_dto['patient'])
104
- if diff.empty?
105
- save_order(patient, order_dto)
106
- order_saved(order_dto)
107
- else
108
- save_failed_import(order_dto, 'Demographics not matching', diff)
109
- end
110
-
111
- update_last_seq(context.current_seq)
112
- rescue DuplicateNHID
113
- logger.warn("Failed to import order due to duplicate patient NHID: #{order_dto[:patient][:id]}")
114
- save_failed_import(order_dto, "Duplicate local patient NHID: #{order_dto[:patient][:id]}")
115
- rescue MissingAccessionNumber
116
- logger.warn("Failed to import order due to missing accession number: #{order_dto[:_id]}")
117
- save_failed_import(order_dto, 'Order missing tracking number')
118
- rescue LimsException => e
119
- logger.warn("Failed to import order due to #{e.class} - #{e.message}")
120
- save_failed_import(order_dto, e.message)
121
- end
122
- end
123
-
124
- protected
125
-
126
- def last_seq
127
- File.open(last_seq_path, File::RDONLY | File::CREAT, 0o644) do |fin|
128
- data = fin.read&.strip
129
- return nil if data.blank?
130
-
131
- return data
132
- end
133
- end
134
-
135
- def update_last_seq(last_seq)
136
- File.open(last_seq_path, File::WRONLY | File::CREAT, 0o644) do |fout|
137
- fout.flock(File::LOCK_EX)
138
-
139
- fout.write(last_seq.to_s)
140
- end
141
- end
142
-
143
- def order_saved(order_dto); end
144
-
145
- def order_rejected(order_dto, message); end
146
-
147
- private
148
-
149
- def find_patient_by_nhid(nhid)
150
- national_id_type = PatientIdentifierType.where(name: ['National id', 'Old Identification Number'])
151
- identifiers = PatientIdentifier.where(type: national_id_type, identifier: nhid)
152
- .joins('INNER JOIN person ON person.person_id = patient_identifier.patient_id AND person.voided = 0')
153
- if identifiers.count.zero?
154
- identifiers = PatientIdentifier.unscoped
155
- .where(voided: 1, type: national_id_type, identifier: nhid)
156
- .joins('INNER JOIN person ON person.person_id = patient_identifier.patient_id AND person.voided = 0')
157
- end
158
-
159
- # Joining to person above to ensure that the person is not voided,
160
- # it was noted at one site that there were some people that were voided
161
- # upon merging but the patient and patient_identifier was not voided
162
-
163
- return nil if identifiers.count.zero?
164
-
165
- patients = Patient.where(patient_id: identifiers.select(:patient_id))
166
- .distinct(:patient_id)
167
- .all
168
-
169
- raise DuplicateNHID, "Duplicate National Health ID: #{nhid}" if patients.size > 1
170
-
171
- patients.first
172
27
  end
173
28
 
174
- ##
175
- # Matches a local patient's demographics to a LIMS patient's demographics
176
- def match_patient_demographics(local_patient, lims_patient)
177
- diff = {}
178
- person = Person.find(local_patient.id)
179
- person_name = PersonName.find_by_person_id(local_patient.id)
29
+ def self.start_pull_worker
30
+ start_worker('pull_worker') do
31
+ api = Lims::Api::RestApi.new(Lab::Lims::Config.rest_api)
32
+ worker = PullWorker.new(api)
180
33
 
181
- unless (person.gender.blank? && lims_patient['gender'].blank?)\
182
- || person.gender&.first&.casecmp?(lims_patient['gender']&.first)
183
- diff[:gender] = { local: person.gender, lims: lims_patient['gender'] }
184
- end
185
-
186
- unless names_match?(person_name&.given_name, lims_patient['first_name'])
187
- diff[:given_name] = { local: person_name&.given_name, lims: lims_patient['first_name'] }
188
- end
189
-
190
- unless names_match?(person_name&.family_name, lims_patient['last_name'])
191
- diff[:family_name] = { local: person_name&.family_name, lims: lims_patient['last_name'] }
34
+ worker.pull_orders
192
35
  end
193
-
194
- diff
195
36
  end
196
37
 
197
- def names_match?(name1, name2)
198
- name1 = name1&.gsub(/'/, '')&.strip
199
- name2 = name2&.gsub(/'/, '')&.strip
200
-
201
- return true if name1.blank? && name2.blank?
38
+ def self.start_realtime_pull_worker
39
+ start_worker('realtime_pull_worker') do
40
+ api = Lims::Api::WsApi.new(Lab::Lims::Config.updates_socket)
41
+ worker = PullWorker.new(api)
202
42
 
203
- return false if name1.blank? || name2.blank?
204
-
205
- name1.casecmp?(name2)
206
- end
207
-
208
- def save_order(patient, order_dto)
209
- raise MissingAccessionNumber if order_dto[:tracking_number].blank?
210
-
211
- logger.info("Importing LIMS order ##{order_dto[:tracking_number]}")
212
- mapping = find_order_mapping_by_lims_id(order_dto[:_id])
213
-
214
- ActiveRecord::Base.transaction do
215
- if mapping
216
- order = update_order(patient, mapping.order_id, order_dto)
217
- mapping.update(pulled_at: Time.now)
218
- else
219
- order = create_order(patient, order_dto)
220
- mapping = LimsOrderMapping.create(lims_id: order_dto[:_id],
221
- order_id: order['id'],
222
- pulled_at: Time.now,
223
- revision: order_dto['_rev'])
224
- end
225
-
226
- order
43
+ worker.pull_orders
227
44
  end
228
45
  end
229
46
 
230
- def create_order(patient, order_dto)
231
- logger.debug("Creating order ##{order_dto['_id']}")
232
- order = OrdersService.order_test(order_dto.to_order_service_params(patient_id: patient.patient_id))
233
- update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
234
-
235
- order
236
- end
237
-
238
- def update_order(patient, order_id, order_dto)
239
- logger.debug("Updating order ##{order_dto['_id']}")
240
- order = OrdersService.update_order(order_id, order_dto.to_order_service_params(patient_id: patient.patient_id)
241
- .merge(force_update: true))
242
- update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
243
-
244
- order
245
- end
246
-
247
- def update_results(order, lims_results)
248
- logger.debug("Updating results for order ##{order[:accession_number]}: #{lims_results}")
249
-
250
- lims_results.each do |test_name, test_results|
251
- test = find_test(order['id'], test_name)
252
- unless test
253
- logger.warn("Couldn't find test, #{test_name}, in order ##{order[:id]}")
254
- next
255
- end
256
-
257
- next unless test_results['results']
258
-
259
- measures = test_results['results'].map do |indicator, value|
260
- measure = find_measure(order, indicator, value)
261
- next nil unless measure
47
+ def self.start_worker(worker_name)
48
+ Rails.logger = LoggerMultiplexor.new(log_path("#{worker_name}.log"), $stdout)
49
+ ActiveRecord::Base.logger = Rails.logger
50
+ Rails.logger.level = :debug
262
51
 
263
- measure
52
+ File.open(log_path("#{worker_name}.lock"), File::RDWR | File::CREAT, 0o644) do |fout|
53
+ unless fout.flock(File::LOCK_EX | File::LOCK_NB)
54
+ Rails.logger.warn("Another process already holds lock #{worker_name} (#{fout.read}), exiting...")
55
+ break
264
56
  end
265
57
 
266
- measures = measures.compact
267
- next if measures.empty?
268
-
269
- creator = format_result_entered_by(test_results['result_entered_by'])
270
-
271
- ResultsService.create_results(test.id, provider_id: User.current.person_id,
272
- date: Utils.parse_date(test_results['date_result_entered'], order[:order_date].to_s),
273
- comments: "LIMS import: Entered by: #{creator}",
274
- measures: measures)
58
+ fout.write("Locked by process ##{Process.pid} under process group ##{Process.ppid} at #{Time.now}")
59
+ fout.flush
60
+ yield
275
61
  end
276
62
  end
277
63
 
278
- def find_test(order_id, test_name)
279
- test_name = Utils.translate_test_name(test_name)
280
- test_concept = Utils.find_concept_by_name(test_name)
281
- raise "Unknown test name, #{test_name}!" unless test_concept
282
-
283
- LabTest.find_by(order_id: order_id, value_coded: test_concept.concept_id)
284
- end
285
-
286
- def find_measure(_order, indicator_name, value)
287
- indicator = Utils.find_concept_by_name(indicator_name)
288
- unless indicator
289
- logger.warn("Result indicator #{indicator_name} not found in concepts list")
290
- return nil
291
- end
292
-
293
- value_modifier, value, value_type = parse_lims_result_value(value)
294
- return nil if value.blank?
295
-
296
- ActiveSupport::HashWithIndifferentAccess.new(
297
- indicator: { concept_id: indicator.concept_id },
298
- value_type: value_type,
299
- value: value_type == 'numeric' ? value.to_f : value,
300
- value_modifier: value_modifier.blank? ? '=' : value_modifier
301
- )
302
- end
303
-
304
- def parse_lims_result_value(value)
305
- value = value['result_value']&.strip
306
- return nil, nil, nil if value.blank?
307
-
308
- match = value&.match(/^(>|=|<|<=|>=)(.*)$/)
309
- return nil, value, guess_result_datatype(value) unless match
310
-
311
- [match[1], match[2], guess_result_datatype(match[2])]
64
+ def self.log_path(filename)
65
+ Lab::Lims::Utils::LIMS_LOG_PATH.join(filename)
312
66
  end
313
67
 
314
- def guess_result_datatype(result)
315
- return 'numeric' if result.strip.match?(/^[+-]?((\d+(\.\d+)?)|\.\d+)$/)
316
-
317
- 'text'
318
- end
319
-
320
- def format_result_entered_by(result_entered_by)
321
- first_name = result_entered_by['first_name']
322
- last_name = result_entered_by['last_name']
323
- phone_number = result_entered_by['phone_number']
324
- id = result_entered_by['id'] # Looks like a user_id of some sort
325
-
326
- "#{id}:#{first_name} #{last_name}:#{phone_number}"
327
- end
328
-
329
- def save_failed_import(order_dto, reason, diff = nil)
330
- logger.info("Failed to import LIMS order ##{order_dto[:tracking_number]} due to '#{reason}'")
331
- LimsFailedImport.create!(lims_id: order_dto[:_id],
332
- tracking_number: order_dto[:tracking_number],
333
- patient_nhid: order_dto[:patient][:id],
334
- reason: reason,
335
- diff: diff&.to_json)
336
- end
337
-
338
- def last_seq_path
339
- LIMS_LOG_PATH.join('last_seq.dat')
340
- end
341
-
342
- def find_order_mapping_by_lims_id(lims_id)
343
- mapping = Lab::LimsOrderMapping.find_by(lims_id: lims_id)
344
- return nil unless mapping
345
-
346
- return mapping if Lab::LabOrder.where(order_id: mapping.order_id).exists?
347
-
348
- mapping.destroy
349
- nil
68
+ def self.realtime_updates_enabled?
69
+ Lims::Config.updates_socket.key?('url')
70
+ rescue Lab::Lims::Config::ConfigNotFound => e
71
+ Rails.logger.warn("Check for realtime updates failed: #{e.message}")
72
+ false
350
73
  end
351
74
  end
352
75
  end