his_emr_api_lab 1.0.4 → 1.1.2

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.
@@ -0,0 +1,104 @@
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'],
60
+ pushed_at: Time.now)
61
+ end
62
+ end
63
+
64
+ order_dto
65
+ end
66
+
67
+ private
68
+
69
+ def orders_pending_sync(batch_size)
70
+ return new_orders.limit(batch_size) if new_orders.exists?
71
+
72
+ return voided_orders.limit(batch_size) if voided_orders.exists?
73
+
74
+ updated_orders.limit(batch_size)
75
+ end
76
+
77
+ def new_orders
78
+ Rails.logger.debug('Looking for new orders that need to be created in LIMS...')
79
+ Lab::LabOrder.where.not(order_id: Lab::LimsOrderMapping.all.select(:order_id))
80
+ end
81
+
82
+ def updated_orders
83
+ Rails.logger.debug('Looking for recently updated orders that need to be pushed to LIMS...')
84
+ last_updated = Lab::LimsOrderMapping.select('MAX(updated_at) AS last_updated')
85
+ .first
86
+ .last_updated
87
+
88
+ Lab::LabOrder.left_joins(:results)
89
+ .where('orders.discontinued_date > :last_updated
90
+ OR obs.date_created > :last_updated',
91
+ last_updated: last_updated)
92
+ .group('orders.order_id')
93
+ end
94
+
95
+ def voided_orders
96
+ Rails.logger.debug('Looking for voided orders that are being tracked by LIMS...')
97
+ Lab::LabOrder.unscoped
98
+ .where(order_type: OrderType.where(name: Lab::Metadata::ORDER_TYPE_NAME),
99
+ order_id: Lab::LimsOrderMapping.all.select(:order_id),
100
+ voided: 1)
101
+ end
102
+ end
103
+ end
104
+ 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