his_emr_api_lab 0.0.2 → 0.0.3

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