mahis_emr_api_lab 1.2.0

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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +71 -0
  4. data/Rakefile +32 -0
  5. data/app/controllers/lab/application_controller.rb +6 -0
  6. data/app/controllers/lab/labels_controller.rb +17 -0
  7. data/app/controllers/lab/orders_controller.rb +78 -0
  8. data/app/controllers/lab/reasons_for_test_controller.rb +9 -0
  9. data/app/controllers/lab/results_controller.rb +20 -0
  10. data/app/controllers/lab/specimen_types_controller.rb +15 -0
  11. data/app/controllers/lab/test_result_indicators_controller.rb +9 -0
  12. data/app/controllers/lab/test_types_controller.rb +15 -0
  13. data/app/controllers/lab/tests_controller.rb +25 -0
  14. data/app/controllers/lab/users_controller.rb +32 -0
  15. data/app/jobs/lab/application_job.rb +4 -0
  16. data/app/jobs/lab/push_order_job.rb +12 -0
  17. data/app/jobs/lab/update_patient_orders_job.rb +32 -0
  18. data/app/jobs/lab/void_order_job.rb +17 -0
  19. data/app/mailers/lab/application_mailer.rb +6 -0
  20. data/app/models/lab/application_record.rb +5 -0
  21. data/app/models/lab/lab_accession_number_counter.rb +13 -0
  22. data/app/models/lab/lab_acknowledgement.rb +6 -0
  23. data/app/models/lab/lab_encounter.rb +7 -0
  24. data/app/models/lab/lab_order.rb +58 -0
  25. data/app/models/lab/lab_result.rb +31 -0
  26. data/app/models/lab/lab_test.rb +19 -0
  27. data/app/models/lab/lims_failed_import.rb +4 -0
  28. data/app/models/lab/lims_order_mapping.rb +10 -0
  29. data/app/models/lab/order_extension.rb +14 -0
  30. data/app/serializers/lab/lab_order_serializer.rb +56 -0
  31. data/app/serializers/lab/result_serializer.rb +36 -0
  32. data/app/serializers/lab/test_serializer.rb +52 -0
  33. data/app/services/lab/accession_number_service.rb +77 -0
  34. data/app/services/lab/acknowledgement_service.rb +47 -0
  35. data/app/services/lab/concepts_service.rb +82 -0
  36. data/app/services/lab/json_web_token_service.rb +20 -0
  37. data/app/services/lab/labelling_service/order_label.rb +106 -0
  38. data/app/services/lab/lims/acknowledgement_serializer.rb +29 -0
  39. data/app/services/lab/lims/acknowledgement_worker.rb +37 -0
  40. data/app/services/lab/lims/api/blackhole_api.rb +21 -0
  41. data/app/services/lab/lims/api/couchdb_api.rb +53 -0
  42. data/app/services/lab/lims/api/mysql_api.rb +316 -0
  43. data/app/services/lab/lims/api/rest_api.rb +434 -0
  44. data/app/services/lab/lims/api/ws_api.rb +121 -0
  45. data/app/services/lab/lims/api_factory.rb +19 -0
  46. data/app/services/lab/lims/config.rb +105 -0
  47. data/app/services/lab/lims/exceptions.rb +11 -0
  48. data/app/services/lab/lims/migrator.rb +216 -0
  49. data/app/services/lab/lims/order_dto.rb +105 -0
  50. data/app/services/lab/lims/order_serializer.rb +251 -0
  51. data/app/services/lab/lims/pull_worker.rb +314 -0
  52. data/app/services/lab/lims/push_worker.rb +152 -0
  53. data/app/services/lab/lims/utils.rb +91 -0
  54. data/app/services/lab/lims/worker.rb +94 -0
  55. data/app/services/lab/metadata.rb +26 -0
  56. data/app/services/lab/notification_service.rb +72 -0
  57. data/app/services/lab/orders_search_service.rb +72 -0
  58. data/app/services/lab/orders_service.rb +330 -0
  59. data/app/services/lab/results_service.rb +166 -0
  60. data/app/services/lab/tests_service.rb +105 -0
  61. data/app/services/lab/user_service.rb +62 -0
  62. data/config/routes.rb +28 -0
  63. data/db/migrate/20210126092910_create_lab_lab_accession_number_counters.rb +12 -0
  64. data/db/migrate/20210310115457_create_lab_lims_order_mappings.rb +15 -0
  65. data/db/migrate/20210323080140_change_lims_id_to_string_in_lims_order_mapping.rb +15 -0
  66. data/db/migrate/20210326195504_add_order_revision_to_lims_order_mapping.rb +5 -0
  67. data/db/migrate/20210407071728_create_lab_lims_failed_imports.rb +19 -0
  68. data/db/migrate/20210610095024_fix_numeric_results_value_type.rb +20 -0
  69. data/db/migrate/20210807111531_add_default_to_lims_order_mapping.rb +7 -0
  70. data/lib/auto12epl.rb +201 -0
  71. data/lib/couch_bum/couch_bum.rb +92 -0
  72. data/lib/generators/lab/install/USAGE +9 -0
  73. data/lib/generators/lab/install/install_generator.rb +19 -0
  74. data/lib/generators/lab/install/templates/rswag-ui-lab.rb +5 -0
  75. data/lib/generators/lab/install/templates/start_worker.rb +32 -0
  76. data/lib/generators/lab/install/templates/swagger.yaml +714 -0
  77. data/lib/lab/engine.rb +13 -0
  78. data/lib/lab/version.rb +5 -0
  79. data/lib/logger_multiplexor.rb +38 -0
  80. data/lib/mahis_emr_api_lab.rb +6 -0
  81. data/lib/tasks/lab_tasks.rake +25 -0
  82. data/lib/tasks/loaders/data/reasons-for-test.csv +7 -0
  83. data/lib/tasks/loaders/data/test-measures.csv +225 -0
  84. data/lib/tasks/loaders/data/tests.csv +161 -0
  85. data/lib/tasks/loaders/loader_mixin.rb +53 -0
  86. data/lib/tasks/loaders/metadata_loader.rb +26 -0
  87. data/lib/tasks/loaders/reasons_for_test_loader.rb +23 -0
  88. data/lib/tasks/loaders/specimens_loader.rb +65 -0
  89. data/lib/tasks/loaders/test_result_indicators_loader.rb +54 -0
  90. metadata +331 -0
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ ##
6
+ # Pulls orders from a Lims API object and saves them to the local database.
7
+ class PullWorker
8
+ attr_reader :lims_api
9
+
10
+ include Utils # for logger
11
+
12
+ LIMS_LOG_PATH = Rails.root.join('log', 'lims')
13
+
14
+ def initialize(lims_api)
15
+ @lims_api = lims_api
16
+ end
17
+
18
+ ##
19
+ # Pulls orders from the LIMS queue and writes them to the local database
20
+ def pull_orders(batch_size: 10_000, **kwargs)
21
+ logger.info("Retrieving LIMS orders starting from #{last_seq}")
22
+
23
+ lims_api.consume_orders(from: last_seq, limit: batch_size, **kwargs) do |order_dto, context|
24
+ logger.debug("Retrieved order ##{order_dto[:tracking_number]}: #{order_dto}")
25
+
26
+ patient = find_patient_by_nhid(order_dto[:patient][:id])
27
+ unless patient
28
+ logger.debug("Discarding order: Non local patient ##{order_dto[:patient][:id]} on order ##{order_dto[:tracking_number]}")
29
+ order_rejected(order_dto, "Patient NPID, '#{order_dto[:patient][:id]}', didn't match any local NPIDs")
30
+ next
31
+ end
32
+
33
+ if order_dto[:tests].empty?
34
+ logger.debug("Discarding order: Missing tests on order ##{order_dto[:tracking_number]}")
35
+ order_rejected(order_dto, 'Order is missing tests')
36
+ next
37
+ end
38
+
39
+ diff = match_patient_demographics(patient, order_dto['patient'])
40
+ if diff.empty?
41
+ save_order(patient, order_dto)
42
+ order_saved(order_dto)
43
+ else
44
+ save_failed_import(order_dto, 'Demographics not matching', diff)
45
+ end
46
+
47
+ update_last_seq(context.current_seq)
48
+ rescue Lab::Lims::DuplicateNHID
49
+ logger.warn("Failed to import order due to duplicate patient NHID: #{order_dto[:patient][:id]}")
50
+ save_failed_import(order_dto, "Duplicate local patient NHID: #{order_dto[:patient][:id]}")
51
+ rescue MissingAccessionNumber
52
+ logger.warn("Failed to import order due to missing accession number: #{order_dto[:_id]}")
53
+ save_failed_import(order_dto, 'Order missing tracking number')
54
+ rescue LimsException => e
55
+ logger.warn("Failed to import order due to #{e.class} - #{e.message}")
56
+ save_failed_import(order_dto, e.message)
57
+ end
58
+ end
59
+
60
+ def process_order(order_dto)
61
+ patient = find_patient_by_nhid(order_dto[:patient][:id])
62
+ unless patient
63
+ logger.debug("Discarding order: Non local patient ##{order_dto[:patient][:id]} on order ##{order_dto[:tracking_number]}")
64
+ order_rejected(order_dto, "Patient NPID, '#{order_dto[:patient][:id]}', didn't match any local NPIDs")
65
+ return
66
+ end
67
+
68
+ if order_dto[:tests].empty?
69
+ logger.debug("Discarding order: Missing tests on order ##{order_dto[:tracking_number]}")
70
+ order_rejected(order_dto, 'Order is missing tests')
71
+ return
72
+ end
73
+
74
+ diff = match_patient_demographics(patient, order_dto['patient'])
75
+ if diff.empty?
76
+ save_order(patient, order_dto)
77
+ order_saved(order_dto)
78
+ else
79
+ save_failed_import(order_dto, 'Demographics not matching', diff)
80
+ end
81
+ end
82
+
83
+ protected
84
+
85
+ def order_saved(order_dto); end
86
+
87
+ def order_rejected(order_dto, message); end
88
+
89
+ def last_seq
90
+ File.open(last_seq_path, File::RDONLY | File::CREAT, 0o644) do |fin|
91
+ data = fin.read&.strip
92
+ return nil if data.blank?
93
+
94
+ return data
95
+ end
96
+ end
97
+
98
+ def update_last_seq(last_seq)
99
+ File.open(last_seq_path, File::WRONLY | File::CREAT, 0o644) do |fout|
100
+ fout.flock(File::LOCK_EX)
101
+
102
+ fout.write(last_seq.to_s)
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def find_patient_by_nhid(nhid)
109
+ national_id_type = PatientIdentifierType.where(name: ['National id', 'Old Identification Number'])
110
+ identifiers = PatientIdentifier.where(type: national_id_type, identifier: nhid)
111
+ .joins('INNER JOIN person ON person.person_id = patient_identifier.patient_id AND person.voided = 0')
112
+ if identifiers.count.zero?
113
+ identifiers = PatientIdentifier.unscoped
114
+ .where(voided: 1, type: national_id_type, identifier: nhid)
115
+ .joins('INNER JOIN person ON person.person_id = patient_identifier.patient_id AND person.voided = 0')
116
+ end
117
+
118
+ # Joining to person above to ensure that the person is not voided,
119
+ # it was noted at one site that there were some people that were voided
120
+ # upon merging but the patient and patient_identifier was not voided
121
+
122
+ return nil if identifiers.count.zero?
123
+
124
+ patients = Patient.where(patient_id: identifiers.select(:patient_id))
125
+ .distinct(:patient_id)
126
+ .all
127
+
128
+ raise Lab::Lims::DuplicateNHID, "Duplicate National Health ID: #{nhid}" if patients.size > 1
129
+
130
+ patients.first
131
+ end
132
+
133
+ ##
134
+ # Matches a local patient's demographics to a LIMS patient's demographics
135
+ def match_patient_demographics(local_patient, lims_patient)
136
+ diff = {}
137
+ person = Person.find(local_patient.id)
138
+ person_name = PersonName.find_by_person_id(local_patient.id)
139
+
140
+ unless (person.gender.blank? && lims_patient['gender'].blank?)\
141
+ || person.gender&.first&.casecmp?(lims_patient['gender']&.first)
142
+ diff[:gender] = { local: person.gender, lims: lims_patient['gender'] }
143
+ end
144
+
145
+ unless names_match?(person_name&.given_name, lims_patient['first_name'])
146
+ diff[:given_name] = { local: person_name&.given_name, lims: lims_patient['first_name'] }
147
+ end
148
+
149
+ unless names_match?(person_name&.family_name, lims_patient['last_name'])
150
+ diff[:family_name] = { local: person_name&.family_name, lims: lims_patient['last_name'] }
151
+ end
152
+
153
+ diff
154
+ end
155
+
156
+ def names_match?(name1, name2)
157
+ name1 = name1&.gsub(/'/, '')&.strip
158
+ name2 = name2&.gsub(/'/, '')&.strip
159
+
160
+ return true if name1.blank? && name2.blank?
161
+
162
+ return false if name1.blank? || name2.blank?
163
+
164
+ name1.casecmp?(name2)
165
+ end
166
+
167
+ def save_order(patient, order_dto)
168
+ raise MissingAccessionNumber if order_dto[:tracking_number].blank?
169
+
170
+ logger.info("Importing LIMS order ##{order_dto[:tracking_number]}")
171
+ mapping = find_order_mapping_by_lims_id(order_dto[:_id])
172
+
173
+ ActiveRecord::Base.transaction do
174
+ if mapping
175
+ order = update_order(patient, mapping.order_id, order_dto)
176
+ mapping.update(pulled_at: Time.now)
177
+ else
178
+ order = create_order(patient, order_dto)
179
+ mapping = LimsOrderMapping.create(lims_id: order_dto[:_id],
180
+ order_id: order['id'],
181
+ pulled_at: Time.now,
182
+ revision: order_dto['_rev'])
183
+ end
184
+
185
+ order
186
+ end
187
+ end
188
+
189
+ def create_order(patient, order_dto)
190
+ logger.debug("Creating order ##{order_dto['_id']}")
191
+ order = OrdersService.order_test(order_dto.to_order_service_params(patient_id: patient.patient_id))
192
+ update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
193
+
194
+ order
195
+ end
196
+
197
+ def update_order(patient, order_id, order_dto)
198
+ logger.debug("Updating order ##{order_dto['_id']}")
199
+ order = OrdersService.update_order(order_id, order_dto.to_order_service_params(patient_id: patient.patient_id)
200
+ .merge(force_update: 'true'))
201
+ update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
202
+
203
+ order
204
+ end
205
+
206
+ def update_results(order, lims_results)
207
+ logger.debug("Updating results for order ##{order[:accession_number]}: #{lims_results}")
208
+
209
+ lims_results.each do |test_name, test_results|
210
+ test = find_test(order['id'], test_name)
211
+ unless test
212
+ logger.warn("Couldn't find test, #{test_name}, in order ##{order[:id]}")
213
+ next
214
+ end
215
+
216
+ next if test.result || test_results['results'].blank?
217
+
218
+ result_date = Time.now
219
+ measures = test_results['results'].map do |indicator, value|
220
+ measure = find_measure(order, indicator, value)
221
+ result_date = value['result_date'] || result_date
222
+ next nil unless measure
223
+
224
+ measure
225
+ end
226
+
227
+ measures = measures.compact
228
+ next if measures.empty?
229
+
230
+ creator = format_result_entered_by(test_results['result_entered_by'])
231
+
232
+ ResultsService.create_results(test.id, { provider_id: User.current.person_id,
233
+ date: Utils.parse_date(test_results['date_result_entered'], result_date),
234
+ comments: "LIMS import: Entered by: #{creator}",
235
+ measures: measures } )
236
+ end
237
+ end
238
+
239
+ def find_test(order_id, test_name)
240
+ test_name = Utils.translate_test_name(test_name)
241
+ test_concept = Utils.find_concept_by_name(test_name)
242
+ raise "Unknown test name, #{test_name}!" unless test_concept
243
+
244
+ LabTest.find_by(order_id: order_id, value_coded: test_concept.concept_id)
245
+ end
246
+
247
+ def find_measure(_order, indicator_name, value)
248
+ indicator = Utils.find_concept_by_name(indicator_name)
249
+ unless indicator
250
+ logger.warn("Result indicator #{indicator_name} not found in concepts list")
251
+ return nil
252
+ end
253
+
254
+ value_modifier, value, value_type = parse_lims_result_value(value)
255
+ return nil if value.blank?
256
+
257
+ ActiveSupport::HashWithIndifferentAccess.new(
258
+ indicator: { concept_id: indicator.concept_id },
259
+ value_type: value_type,
260
+ value: value_type == 'numeric' ? value.to_f : value,
261
+ value_modifier: value_modifier.blank? ? '=' : value_modifier
262
+ )
263
+ end
264
+
265
+ def parse_lims_result_value(value)
266
+ value = value['result_value']&.strip
267
+ return nil, nil, nil if value.blank?
268
+
269
+ match = value&.match(/^(>|=|<|<=|>=)(.*)$/)
270
+ return nil, value, guess_result_datatype(value) unless match
271
+
272
+ [match[1], match[2].strip, guess_result_datatype(match[2])]
273
+ end
274
+
275
+ def guess_result_datatype(result)
276
+ return 'numeric' if result.strip.match?(/^[+-]?((\d+(\.\d+)?)|\.\d+)$/)
277
+
278
+ 'text'
279
+ end
280
+
281
+ def format_result_entered_by(result_entered_by)
282
+ first_name = result_entered_by['first_name']
283
+ last_name = result_entered_by['last_name']
284
+ phone_number = result_entered_by['phone_number']
285
+ id = result_entered_by['id'] # Looks like a user_id of some sort
286
+
287
+ "#{id}:#{first_name} #{last_name}:#{phone_number}"
288
+ end
289
+
290
+ def save_failed_import(order_dto, reason, diff = nil)
291
+ logger.info("Failed to import LIMS order ##{order_dto[:tracking_number]} due to '#{reason}'")
292
+ LimsFailedImport.create!(lims_id: order_dto[:_id],
293
+ tracking_number: order_dto[:tracking_number],
294
+ patient_nhid: order_dto[:patient][:id],
295
+ reason: reason,
296
+ diff: diff&.to_json)
297
+ end
298
+
299
+ def last_seq_path
300
+ LIMS_LOG_PATH.join('last_seq.dat')
301
+ end
302
+
303
+ def find_order_mapping_by_lims_id(lims_id)
304
+ mapping = Lab::LimsOrderMapping.find_by(lims_id: lims_id)
305
+ return nil unless mapping
306
+
307
+ return mapping if Lab::LabOrder.where(order_id: mapping.order_id).exists?
308
+
309
+ mapping.destroy
310
+ nil
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ ##
6
+ # Pushes all local orders to a LIMS Api object.
7
+ class PushWorker
8
+ attr_reader :lims_api
9
+
10
+ include Utils # for logger
11
+
12
+ SECONDS_TO_WAIT_FOR_ORDERS = 30
13
+
14
+ def initialize(lims_api)
15
+ @lims_api = lims_api
16
+ end
17
+
18
+ def push_orders(batch_size: 1000, wait: false)
19
+ loop do
20
+ logger.info('Looking for new orders to push to LIMS...')
21
+ orders = orders_pending_sync(batch_size).all
22
+
23
+ logger.debug("Found #{orders.size} orders...")
24
+ orders.each do |order|
25
+ push_order(order)
26
+ rescue GatewayError => e
27
+ logger.error("Failed to push order ##{order.accession_number}: #{e.class} - #{e.message}")
28
+ rescue StandardError => e
29
+ logger.error("Failed to push order ##{order.id}: #{order&.accession_number} : #{e.class} - #{e.message}")
30
+ end
31
+
32
+ break unless wait
33
+
34
+ logger.info('Waiting for orders...')
35
+ sleep(Lab::Lims::Config.updates_poll_frequency)
36
+ end
37
+ end
38
+
39
+ def push_order_by_id(order_id)
40
+ order = Lab::LabOrder.joins(order_type: { name: 'Lab' })
41
+ .unscoped
42
+ .find(order_id)
43
+ push_order(order)
44
+ end
45
+
46
+ ##
47
+ # Pushes given order to LIMS queue
48
+ def push_order(order)
49
+ logger.info("Pushing order ##{order.order_id}")
50
+
51
+ order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
52
+ mapping = Lab::LimsOrderMapping.find_by(order_id: order.order_id)
53
+
54
+ ActiveRecord::Base.transaction do
55
+ if mapping && !order.voided.zero?
56
+ Rails.logger.info("Deleting order ##{order_dto[:accession_number]} from LIMS")
57
+ lims_api.delete_order(mapping.lims_id, order_dto)
58
+ mapping.destroy
59
+ elsif mapping
60
+ Rails.logger.info("Updating order ##{order_dto[:accession_number]} in LIMS")
61
+ lims_api.update_order(mapping.lims_id, order_dto)
62
+ if order_dto['test_results'].nil? || order_dto['test_results'].empty?
63
+ mapping.update(pushed_at: Time.now)
64
+ else
65
+ mapping.update(pushed_at: Time.now, result_push_status: true)
66
+ end
67
+ elsif order_dto[:_id] && Lab::LimsOrderMapping.where(lims_id: order_dto[:_id]).exists?
68
+ # HACK: v1.1.7 had a bug where duplicates of recently created orders where being created by
69
+ # the pull worker. This here detects those duplicates and voids them.
70
+ Rails.logger.warn("Duplicate accession number found: #{order_dto[:_id]}, skipping order...")
71
+ fix_duplicates!(order)
72
+ else
73
+ Rails.logger.info("Creating order ##{order_dto[:accession_number]} in LIMS")
74
+ update = lims_api.create_order(order_dto)
75
+ Lab::LimsOrderMapping.create!(order: order, lims_id: update['id'], revision: update['rev'],
76
+ pushed_at: Time.now, result_push_status: false)
77
+ end
78
+ end
79
+
80
+ order_dto
81
+ end
82
+
83
+ private
84
+
85
+ def orders_pending_sync(batch_size)
86
+ return new_orders.limit(batch_size) if new_orders.exists?
87
+
88
+ return voided_orders.limit(batch_size) if voided_orders.exists?
89
+
90
+ updated_orders.limit(batch_size)
91
+ end
92
+
93
+ def new_orders
94
+ Rails.logger.debug('Looking for new orders that need to be created in LIMS...')
95
+ Lab::LabOrder.where.not(order_id: Lab::LimsOrderMapping.all.select(:order_id))
96
+ .where("accession_number IS NOT NULL AND accession_number !=''")
97
+ .order(date_created: :desc)
98
+ end
99
+
100
+ def updated_orders
101
+ Rails.logger.debug('Looking for recently updated orders that need to be pushed to LIMS...')
102
+ last_updated = Lab::LimsOrderMapping.select('MAX(updated_at) AS last_updated')
103
+ .first
104
+ .last_updated
105
+
106
+ Lab::LabOrder.left_joins(:results)
107
+ .joins(:mapping)
108
+ .where('orders.discontinued_date > :last_updated
109
+ OR obs.date_created > orders.date_created AND lab_lims_order_mappings.result_push_status = 0',
110
+ last_updated: last_updated)
111
+ .group('orders.order_id')
112
+ .order(discontinued_date: :desc, date_created: :desc)
113
+ end
114
+
115
+ def voided_orders
116
+ Rails.logger.debug('Looking for voided orders that are being tracked by LIMS...')
117
+ Lab::LabOrder.unscoped
118
+ .where(order_type: OrderType.where(name: Lab::Metadata::ORDER_TYPE_NAME),
119
+ order_id: Lab::LimsOrderMapping.all.select(:order_id),
120
+ voided: 1)
121
+ .order(date_voided: :desc)
122
+ end
123
+
124
+ ##
125
+ # HACK: Checks for duplicates previously created by version 1.1.7 pull worker bug due to this proving orders
126
+ # that have not been pushed to LIMS as orders awaiting updates.
127
+ def fix_duplicates!(order)
128
+ return order.void('Duplicate created by bug in HIS-EMR-API-Lab v1.1.7') unless order_has_specimen?(order)
129
+
130
+ duplicate_order = Lab::LabOrder.where(accession_number: order.accession_number)
131
+ .where.not(order_id: order.order_id)
132
+ .first
133
+ return unless duplicate_order
134
+
135
+ if !order_has_results?(order) && (order_has_results?(duplicate_order) || order_has_specimen?(duplicate_order))
136
+ order.void('DUplicate created by bug in HIS-EMR-API-Lab v1.1.7')
137
+ else
138
+ duplicate_order.void('Duplicate created by bug in HIS-EMR-API-Lab v1.1.7')
139
+ Lab::LimsOrderMapping.find_by_lims_id(order.accession_number)&.destroy
140
+ end
141
+ end
142
+
143
+ def order_has_results?(order)
144
+ order.results.exists?
145
+ end
146
+
147
+ def order_has_specimen?(order)
148
+ order.concept_id == ConceptName.find_by_name!('Unknown').concept_id
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi/util'
4
+
5
+ module Lab
6
+ module Lims
7
+ ##
8
+ # Various helper methods for modules in the Lims namespaces...
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
+
13
+ def logger
14
+ Rails.logger
15
+ end
16
+
17
+ TEST_NAME_MAPPINGS = {
18
+ # For some weird reason(s) some tests have multiple names in LIMS,
19
+ # this is used to sanitize those names.
20
+ 'hiv_viral_load' => 'HIV Viral Load',
21
+ 'viral laod' => 'HIV Viral Load',
22
+ 'viral load' => 'HIV Viral Load',
23
+ 'i/ink' => 'India ink',
24
+ 'indian ink' => 'India ink'
25
+ }.freeze
26
+
27
+ def self.translate_test_name(test_name)
28
+ TEST_NAME_MAPPINGS.fetch(test_name.downcase, test_name)
29
+ end
30
+
31
+ def self.structify(object)
32
+ if object.is_a?(Hash)
33
+ object.each_with_object(OpenStruct.new) do |kv_pair, struct|
34
+ key, value = kv_pair
35
+
36
+ struct[key] = structify(value)
37
+ end
38
+ elsif object.respond_to?(:map)
39
+ object.map { |item| structify(item) }
40
+ else
41
+ object
42
+ end
43
+ end
44
+
45
+ def self.lab_user
46
+ user = User.find_by_username('lab_daemon')
47
+ return user if user
48
+
49
+ god_user = User.first
50
+ User.current = god_user
51
+ person = Person.create!(creator: god_user.user_id, birthdate: '1980-01-01')
52
+ PersonName.create!(person: person, given_name: 'Lab', family_name: 'Daemon', creator: god_user.user_id)
53
+
54
+ User.create!(username: 'lab_daemon', person: person, creator: god_user.user_id)
55
+ end
56
+
57
+ def self.parse_date(str_date, fallback_date = nil)
58
+ str_date = str_date&.to_s
59
+
60
+ if str_date.blank? && fallback_date.blank?
61
+ raise "Can't parse blank date"
62
+ end
63
+
64
+ return parse_date(fallback_date) if str_date.blank?
65
+
66
+ str_date = str_date.gsub(/^00/, '20').gsub(/^180/, '20')
67
+
68
+ case str_date
69
+ when /\d{4}-\d{2}-\d{2}/
70
+ str_date
71
+ when /\d{2}-\d{2}-\d{2}/
72
+ Date.strptime(str_date, '%d-%m-%Y').strftime('%Y-%m-%d')
73
+ when /(\d{4}\d{2}\d{2})\d+/
74
+ Date.strptime(str_date, '%Y%m%d').strftime('%Y-%m-%d')
75
+ when %r{\d{2}/\d{2}/\d{4}}
76
+ str_date.to_date.to_s
77
+ else
78
+ Rails.logger.warn("Invalid date: #{str_date}")
79
+ parse_date(fallback_date)
80
+ end
81
+ end
82
+
83
+ def self.find_concept_by_name(name)
84
+ ConceptName.joins(:concept)
85
+ .merge(Concept.all) # Filter out voided
86
+ .where(name: CGI.unescapeHTML(name))
87
+ .first
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger_multiplexor'
4
+
5
+ require_relative './api/couchdb_api'
6
+
7
+ module Lab
8
+ module Lims
9
+ ##
10
+ # Pull/Push orders from/to the LIMS queue (Oops meant CouchDB).
11
+ module Worker
12
+ def self.start
13
+ User.current = Utils.lab_user
14
+
15
+ fork(&method(:start_push_worker))
16
+ # fork(&method(:start_pull_worker))
17
+ fork(&method(:start_acknowledgement_worker))
18
+ fork(&method(:start_realtime_pull_worker)) if realtime_updates_enabled?
19
+
20
+ Process.waitall
21
+ end
22
+
23
+ def self.start_push_worker
24
+ start_worker('push_worker') do
25
+ worker = PushWorker.new(lims_api)
26
+
27
+ worker.push_orders # (wait: true)
28
+ end
29
+ end
30
+
31
+ def self.start_acknowledgement_worker
32
+ start_worker('acknowledgement_worker') do
33
+ worker = AcknowledgementWorker.new(lims_api)
34
+ worker.push_acknowledgement
35
+ end
36
+ end
37
+
38
+ def self.start_pull_worker
39
+ start_worker('pull_worker') do
40
+ worker = PullWorker.new(lims_api)
41
+
42
+ worker.pull_orders
43
+ end
44
+ end
45
+
46
+ def self.start_realtime_pull_worker
47
+ start_worker('realtime_pull_worker') do
48
+ worker = PullWorker.new(Lims::Api::WsApi.new(Lab::Lims::Config.updates_socket))
49
+
50
+ worker.pull_orders
51
+ end
52
+ end
53
+
54
+ LOG_FILES_TO_KEEP = 5
55
+ LOG_FILE_SIZE = 500.megabytes
56
+
57
+ def self.start_worker(worker_name)
58
+ Rails.logger = LoggerMultiplexor.new(file_logger(worker_name), $stdout)
59
+ ActiveRecord::Base.logger = Rails.logger
60
+ Rails.logger.level = :debug
61
+
62
+ File.open(log_path("#{worker_name}.lock"), File::RDWR | File::CREAT, 0o644) do |fout|
63
+ unless fout.flock(File::LOCK_EX | File::LOCK_NB)
64
+ Rails.logger.warn("Another process already holds lock #{worker_name} (#{fout.read}), exiting...")
65
+ break
66
+ end
67
+
68
+ fout.write("Locked by process ##{Process.pid} under process group ##{Process.ppid} at #{Time.now}")
69
+ fout.flush
70
+ yield
71
+ end
72
+ end
73
+
74
+ def self.file_logger(worker_name)
75
+ Logger.new(log_path("#{worker_name}.log"), LOG_FILES_TO_KEEP, LOG_FILE_SIZE)
76
+ end
77
+
78
+ def self.log_path(filename)
79
+ Lab::Lims::Utils::LIMS_LOG_PATH.join(filename)
80
+ end
81
+
82
+ def self.realtime_updates_enabled?
83
+ Lims::Config.updates_socket.key?('url')
84
+ rescue Lab::Lims::Config::ConfigNotFound => e
85
+ Rails.logger.warn("Check for realtime updates failed: #{e.message}")
86
+ false
87
+ end
88
+
89
+ def self.lims_api
90
+ Lab::Lims::ApiFactory.create_api
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Metadata
5
+ # Concepts
6
+ REASON_FOR_TEST_CONCEPT_NAME = 'Reason for test'
7
+ REQUESTING_CLINICIAN_CONCEPT_NAME = 'Person making request'
8
+ SPECIMEN_TYPE_CONCEPT_NAME = 'Specimen type'
9
+ TARGET_LAB_CONCEPT_NAME = 'Lab'
10
+ TEST_RESULT_CONCEPT_NAME = 'Lab test result'
11
+ TEST_RESULT_INDICATOR_CONCEPT_NAME = 'Lab test result indicator'
12
+ TEST_TYPE_CONCEPT_NAME = 'Test type'
13
+ LAB_ORDER_STATUS_CONCEPT_NAME = 'lab order status'
14
+ UNKNOWN_SPECIMEN = 'Unknown'
15
+ VISIT_TYPE_UUID = '8a4a4488-c2cc-11de-8d13-0010c6dffd0f'
16
+
17
+ # Encounter
18
+ ENCOUNTER_TYPE_NAME = 'Lab'
19
+
20
+ # Order types
21
+ ORDER_TYPE_NAME = 'Lab'
22
+
23
+ # Programs
24
+ LAB_PROGRAM_NAME = 'Laboratory Program'
25
+ end
26
+ end