his_emr_api_lab 0.0.2 → 0.0.3

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,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