his_emr_api_lab 0.0.2 → 0.0.7

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