mahis_his_emr_api_lab 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
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/his_emr_api_lab.rb +5 -0
  78. data/lib/lab/engine.rb +15 -0
  79. data/lib/lab/version.rb +5 -0
  80. data/lib/logger_multiplexor.rb +38 -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