his_emr_api_lab 0.0.2 → 0.0.7

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.
@@ -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