his_emr_api_lab 0.0.11 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 060337303a9a04fc0f9cebbe525f2275964b41776297b440bc9d7ea4c25c39b8
4
- data.tar.gz: 5cc5383ab62d941de6a0d5fa2f51c5199461f141459cea072b40c004277855d1
3
+ metadata.gz: 7e6be868d3a1113b834bf4e6644734eead3e01d7b51f90e310e3020eb8125b6c
4
+ data.tar.gz: bd7ca74bdf0af442d3a4c6f9a1f9e0a73d4d61571f1e39ae6ce5352246cd801d
5
5
  SHA512:
6
- metadata.gz: 25e70f8e501dd0f0edf25b6f202f41129842fe5099cd5e6d977035df063d9233ac38b98e51ebdd2c2bcb382524da111b42138815d0a48c02e5bdb979b43c8127
7
- data.tar.gz: 950d9d988b026ac2dbaed9f63be620cc474816aaba77c64231291e721e463f2872a20c2f629a553d1c52c0689e20afb28d44fa1c981807e1a0c4ed7945d6103f
6
+ metadata.gz: 7c122f5fbc967d7e470aac9eb455f77d9471a40caa582a9306d4558a25094d8efba17a9aca25ea312609ac9c7571cf154f463a0c65a55387bb8a030dcf48b7c6
7
+ data.tar.gz: 9c68ca05bb1accaac6942c37f2399ae5ef5f94bde87cbd30811f35e394a9b2a43e2671f266173839156b980c3030051fc5e9fc4103c3b8f41a7dcd84e5324e35
@@ -60,7 +60,7 @@ module Lab
60
60
  def drawer
61
61
  return 'N/A' if order.concept_id == unknown_concept.concept_id
62
62
 
63
- drawer_id = order.discontinued_by || order.creator
63
+ drawer_id = User.find(order.discontinued_by || order.creator).person_id
64
64
  draw_date = (order.discontinued_date || order.start_date).strftime('%d/%^b/%Y %H:%M:%S')
65
65
 
66
66
  name = PersonName.find_by_person_id(drawer_id)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'couch_bum/couch_bum'
4
+
5
+ require_relative '../config'
6
+
7
+ module Lab
8
+ module Lims
9
+ module Api
10
+ ##
11
+ # Talk to LIMS like a boss
12
+ class CouchDbApi
13
+ attr_reader :bum
14
+
15
+ def initialize(config: nil)
16
+ config ||= Config.couchdb
17
+
18
+ @bum = CouchBum.new(protocol: config['protocol'],
19
+ host: config['host'],
20
+ port: config['port'],
21
+ database: "#{config['prefix']}_order_#{config['suffix']}",
22
+ username: config['username'],
23
+ password: config['password'])
24
+ end
25
+
26
+ ##
27
+ # Consume orders from the LIMS queue.
28
+ #
29
+ # Retrieves orders from the LIMS queue and passes each order to
30
+ # given block until the queue is empty or connection is terminated
31
+ # by calling method +choke+.
32
+ def consume_orders(from: 0, limit: 30)
33
+ bum.binge_changes(since: from, limit: limit, include_docs: true) do |change|
34
+ next unless change['doc']['type']&.casecmp?('Order')
35
+
36
+ yield OrderDTO.new(change['doc']), self
37
+ end
38
+ end
39
+
40
+ def create_order(order)
41
+ bum.couch_rest :post, '/', order
42
+ end
43
+
44
+ def update_order(id, order)
45
+ bum.couch_rest :put, "/#{id}", order
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ module Api
6
+ class MysqlApi
7
+ def self.start
8
+ instance = MysqlApi.new
9
+ orders_processed = 0
10
+ instance.consume_orders(from: 0, limit: 1000) do |order|
11
+ puts "Order ##{orders_processed}"
12
+ pp order
13
+ orders_processed += 1
14
+ puts
15
+ end
16
+ end
17
+
18
+ def initialize(processes: 1, on_merge_processes: nil)
19
+ @processes = processes
20
+ @on_merge_processes = on_merge_processes
21
+ @mysql_connection_pool = {}
22
+ end
23
+
24
+ def multiprocessed?
25
+ @processes > 1
26
+ end
27
+
28
+ def consume_orders(from: nil, limit: 1000)
29
+ loop do
30
+ specimens_to_process = specimens(from, limit)
31
+ break if specimens_to_process.size.zero?
32
+
33
+ processes = multiprocessed? ? @processes : 0
34
+ on_merge_processes = ->(_item, index, _result) { @on_merge_processes&.call(from + index) }
35
+
36
+ Parallel.map(specimens_to_process, in_processes: processes, finish: on_merge_processes) do |specimen|
37
+ User.current ||= Utils.lab_user
38
+
39
+ tests = specimen_tests(specimen['specimen_id'])
40
+ results = tests.each_with_object({}) do |test, object|
41
+ object[test['test_name']] = test_results(test['test_id'])
42
+ end
43
+
44
+ dto = make_order_dto(
45
+ specimen: specimen,
46
+ patient: specimen_patient(specimen['specimen_id']),
47
+ test_results: results,
48
+ specimen_status_trail: specimen_status_trail(specimen['specimen_id']),
49
+ test_status_trail: tests.each_with_object({}) do |test, trails|
50
+ trails[test['test_name']] = test_status_trail(test['test_id'])
51
+ end
52
+ )
53
+
54
+ yield dto, OpenStruct.new(last_seq: from)
55
+ end
56
+
57
+ from += limit
58
+ end
59
+ end
60
+
61
+ def parallel_map(items, on_merge: nil, &block); end
62
+
63
+ private
64
+
65
+ def specimens(start_id, limit)
66
+ query = <<~SQL
67
+ SELECT specimen.id AS specimen_id,
68
+ specimen.couch_id AS doc_id,
69
+ specimen_types.name AS specimen_name,
70
+ specimen.tracking_number,
71
+ specimen.priority,
72
+ specimen.target_lab,
73
+ specimen.sending_facility,
74
+ specimen.drawn_by_id,
75
+ specimen.drawn_by_name,
76
+ specimen.drawn_by_phone_number,
77
+ specimen.ward_id,
78
+ specimen_statuses.name AS specimen_status,
79
+ specimen.district,
80
+ specimen.date_created AS order_date
81
+ FROM specimen
82
+ INNER JOIN specimen_types ON specimen_types.id = specimen.specimen_type_id
83
+ INNER JOIN specimen_statuses ON specimen_statuses.id = specimen.specimen_status_id
84
+ SQL
85
+
86
+ query = "#{query} WHERE specimen.id > #{sql_escape(start_id)}" if start_id
87
+ query = "#{query} LIMIT #{limit.to_i}"
88
+
89
+ Rails.logger.debug(query)
90
+ query(query)
91
+ end
92
+
93
+ ##
94
+ # Pull patient associated with given specimen
95
+ def specimen_patient(specimen_id)
96
+ results = query <<~SQL
97
+ SELECT patients.patient_number AS nhid,
98
+ patients.name,
99
+ patients.gender,
100
+ DATE(patients.dob) AS birthdate
101
+ FROM patients
102
+ INNER JOIN tests
103
+ ON tests.patient_id = patients.id
104
+ AND tests.specimen_id = #{sql_escape(specimen_id)}
105
+ LIMIT 1
106
+ SQL
107
+
108
+ results.first
109
+ end
110
+
111
+ def specimen_tests(specimen_id)
112
+ query <<~SQL
113
+ SELECT tests.id AS test_id,
114
+ test_types.name AS test_name,
115
+ tests.created_by AS drawn_by_name
116
+ FROM tests
117
+ INNER JOIN test_types ON test_types.id = tests.test_type_id
118
+ WHERE tests.specimen_id = #{sql_escape(specimen_id)}
119
+ SQL
120
+ end
121
+
122
+ def specimen_status_trail(specimen_id)
123
+ query <<~SQL
124
+ SELECT specimen_statuses.name AS status_name,
125
+ specimen_status_trails.who_updated_id AS updated_by_id,
126
+ specimen_status_trails.who_updated_name AS updated_by_name,
127
+ specimen_status_trails.who_updated_phone_number AS updated_by_phone_number,
128
+ specimen_status_trails.time_updated AS date
129
+ FROM specimen_status_trails
130
+ INNER JOIN specimen_statuses
131
+ ON specimen_statuses.id = specimen_status_trails.specimen_status_id
132
+ WHERE specimen_status_trails.specimen_id = #{sql_escape(specimen_id)}
133
+ SQL
134
+ end
135
+
136
+ def test_status_trail(test_id)
137
+ query <<~SQL
138
+ SELECT test_statuses.name AS status_name,
139
+ test_status_trails.who_updated_id AS updated_by_id,
140
+ test_status_trails.who_updated_name AS updated_by_name,
141
+ test_status_trails.who_updated_phone_number AS updated_by_phone_number,
142
+ COALESCE(test_status_trails.time_updated, test_status_trails.created_at) AS date
143
+ FROM test_status_trails
144
+ INNER JOIN test_statuses
145
+ ON test_statuses.id = test_status_trails.test_status_id
146
+ WHERE test_status_trails.test_id = #{sql_escape(test_id)}
147
+ SQL
148
+ end
149
+
150
+ def test_results(test_id)
151
+ query <<~SQL
152
+ SELECT measures.name AS measure_name,
153
+ test_results.result,
154
+ test_results.time_entered AS date
155
+ FROM test_results
156
+ INNER JOIN measures ON measures.id = test_results.measure_id
157
+ WHERE test_results.test_id = #{sql_escape(test_id)}
158
+ SQL
159
+ end
160
+
161
+ def make_order_dto(specimen:, patient:, test_status_trail:, specimen_status_trail:, test_results:)
162
+ drawn_by_first_name, drawn_by_last_name = specimen['drawn_by_name']&.split
163
+ patient_first_name, patient_last_name = patient['name'].split
164
+
165
+ OrderDTO.new(
166
+ _id: specimen['doc_id'].blank? ? SecureRandom.uuid : specimen['doc_id'],
167
+ _rev: '0',
168
+ tracking_number: specimen['tracking_number'],
169
+ date_created: specimen['order_date'],
170
+ sample_type: specimen['specimen_name'],
171
+ tests: test_status_trail.keys,
172
+ districy: specimen['district'], # districy [sic] - That's how it's named
173
+ order_location: specimen['ward_id'],
174
+ sending_facility: specimen['sending_facility'],
175
+ receiving_facility: specimen['target_lab'],
176
+ priority: specimen['priority'],
177
+ patient: {
178
+ id: patient['nhid'],
179
+ first_name: patient_first_name,
180
+ last_name: patient_last_name,
181
+ gender: patient['gender'],
182
+ birthdate: patient['birthdate'],
183
+ email: nil,
184
+ phone_number: nil
185
+ },
186
+ type: 'Order',
187
+ who_order_test: {
188
+ first_name: drawn_by_first_name,
189
+ last_name: drawn_by_last_name,
190
+ id: specimen['drawn_by_id'],
191
+ phone_number: specimen['drawn_by_phone_number']
192
+ },
193
+ sample_status: specimen['specimen_status'],
194
+ sample_statuses: specimen_status_trail.each_with_object({}) do |trail_entry, object|
195
+ first_name, last_name = trail_entry['updated_by_name'].split
196
+
197
+ object[format_date(trail_entry['date'])] = {
198
+ status: trail_entry['status_name'],
199
+ updated_by: {
200
+ first_name: first_name,
201
+ last_name: last_name,
202
+ phone_number: trail_entry['updated_by_phone_number'],
203
+ id: trail_entry['updated_by_id']
204
+ }
205
+ }
206
+ end,
207
+ test_statuses: test_status_trail.each_with_object({}) do |trail_entry, formatted_trail|
208
+ test_name, test_statuses = trail_entry
209
+
210
+ formatted_trail[test_name] = test_statuses.each_with_object({}) do |test_status, formatted_statuses|
211
+ updated_by_first_name, updated_by_last_name = test_status['updated_by_name'].split
212
+
213
+ formatted_statuses[format_date(test_status['date'])] = {
214
+ status: test_status['status_name'],
215
+ updated_by: {
216
+ first_name: updated_by_first_name,
217
+ last_name: updated_by_last_name,
218
+ phone_number: test_status['updated_by_phone_number'],
219
+ id: test_status['updated_by_id']
220
+ }
221
+ }
222
+ end
223
+ end,
224
+ test_results: test_results.each_with_object({}) do |results_entry, formatted_results|
225
+ test_name, results = results_entry
226
+
227
+ formatted_results[test_name] = format_test_result_for_dto(test_name, specimen, results, test_status_trail)
228
+ end
229
+ )
230
+ end
231
+
232
+ def format_test_result_for_dto(test_name, specimen, results, test_status_trail)
233
+ return {} if results.size.zero?
234
+
235
+ result_create_event = test_status_trail[test_name]&.find do |trail_entry|
236
+ trail_entry['status_name'].casecmp?('drawn')
237
+ end
238
+
239
+ result_creator_first_name, result_creator_last_name = result_create_event&.fetch('updated_by_name')&.split
240
+ unless result_creator_first_name
241
+ result_creator_first_name, result_creator_last_name = specimen['drawn_by_name']&.split
242
+ end
243
+
244
+ {
245
+ results: results.each_with_object({}) do |result, formatted_measures|
246
+ formatted_measures[result['measure_name']] = {
247
+ result_value: result['result']
248
+ }
249
+ end,
250
+ date_result_entered: format_date(result_create_event&.fetch('date') || specimen['order_date'], :iso),
251
+ result_entered_by: {
252
+ first_name: result_creator_first_name,
253
+ last_name: result_creator_last_name,
254
+ phone_number: result_create_event&.fetch('updated_by_phone_number') || specimen['drawn_by_phone_number'],
255
+ id: result_create_event&.fetch('updated_by_id') || specimen['updated_by_id']
256
+ }
257
+ }
258
+ end
259
+
260
+ def mysql
261
+ return mysql_connection if mysql_connection
262
+
263
+ config = lambda do |key|
264
+ @config ||= Lab::Lims::Config.database
265
+ @config['default'][key] || @config['development'][key]
266
+ end
267
+
268
+ connection = Mysql2::Client.new(host: config['host'] || 'localhost',
269
+ username: config['username'] || 'root',
270
+ password: config['password'],
271
+ port: config['port'] || '3306',
272
+ database: config['database'],
273
+ reconnect: true)
274
+
275
+ self.mysql_connection = connection
276
+ end
277
+
278
+ def pid
279
+ return -1 if Parallel.worker_number.nil?
280
+
281
+ Parallel.worker_number
282
+ end
283
+
284
+ def mysql_connection=(connection)
285
+ @mysql_connection_pool[pid] = connection
286
+ end
287
+
288
+ def mysql_connection
289
+ @mysql_connection_pool[pid]
290
+ end
291
+
292
+ def query(sql)
293
+ Rails.logger.debug("#{MysqlApi}: #{sql}")
294
+ mysql.query(sql)
295
+ end
296
+
297
+ def sql_escape(value)
298
+ mysql.escape(value.to_s)
299
+ end
300
+
301
+ ##
302
+ # Lims has some weird date formatting standards...
303
+ def format_date(date, format = nil)
304
+ date = date&.to_time
305
+
306
+ case format
307
+ when :iso
308
+ date&.strftime('%Y-%m-%d %H:%M:%S')
309
+ else
310
+ date&.strftime('%Y%m%d%H%M%S')
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end
@@ -29,6 +29,12 @@ module Lab
29
29
  YAML.load_file(find_config_path('application.yml'))
30
30
  end
31
31
 
32
+ ##
33
+ # Returns LIMS' database.yml configuration file
34
+ def database
35
+ YAML.load_file(find_config_path('database.yml'))
36
+ end
37
+
32
38
  private
33
39
 
34
40
  ##
@@ -39,10 +45,11 @@ module Lab
39
45
  paths = [
40
46
  "#{ENV['HOME']}/apps/nlims_controller/config/#{filename}",
41
47
  "/var/www/nlims_controller/config/#{filename}",
42
- Rails.root.parent.join("nlims_controller/config/#{filename}"),
43
- Rails.root.join('config/lims-couch.yml')
48
+ Rails.root.parent.join("nlims_controller/config/#{filename}")
44
49
  ]
45
50
 
51
+ paths = [Rails.root.join('config/lims-couchdb.yml'), *paths] if filename == 'couchdb.yml'
52
+
46
53
  paths.each do |path|
47
54
  Rails.logger.debug("Looking for LIMS couchdb config at: #{path}")
48
55
  return path if File.exist?(path)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './api'
4
+ require_relative './order_dto'
5
+
6
+ module Lab
7
+ module Lims
8
+ ##
9
+ # Manage LIMS orders that failed to import
10
+ module FailedImports
11
+ class << self
12
+ ##
13
+ # Retrieve all imports that failed
14
+ def failed_imports(start_id = 0, limit = 20)
15
+ Lab::LimsFailedImport.where('id >= ?', start_id).limit(limit)
16
+ end
17
+
18
+ ##
19
+ # Forcefully imports a failed import into a patient
20
+ def force_import(failed_import_id, _patient_id)
21
+ failed_import = Lab::LimsFailedImport.find(failed_import_id)
22
+ order_dto = Lab::Lims::OrderDTO.new(lims_api.find_order(failed_import.lims_id))
23
+ byebug
24
+ end
25
+
26
+ private
27
+
28
+ def lims_api
29
+ @lims_api ||= Lab::Lims::Api.new
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -29,6 +29,7 @@ require 'lab/lab_test'
29
29
  require 'lab/lims_order_mapping'
30
30
  require 'lab/lims_failed_import'
31
31
 
32
+ require_relative './config'
32
33
  require_relative './worker'
33
34
  require_relative '../orders_service'
34
35
  require_relative '../results_service'
@@ -43,53 +44,73 @@ require_relative 'utils'
43
44
  module Lab
44
45
  module Lims
45
46
  module Migrator
46
- class MigratorApi < Api
47
- MAX_THREADS = 6
47
+ MAX_THREADS = ENV.fetch('MIGRATION_WORKERS', 6).to_i
48
48
 
49
- attr_reader :rejections
49
+ class CouchDbMigratorApi < Api::CouchDbApi
50
+ def initialize(*args, processes: 1, on_merge_processes: nil, **kwargs)
51
+ super(*args, **kwargs)
52
+
53
+ @processes = processes
54
+ @on_merge_processes = on_merge_processes
55
+ end
50
56
 
51
57
  def consume_orders(from: nil, **_kwargs)
52
- limit = 50_000
58
+ limit = 25_000
53
59
 
54
- Parallel.each(read_orders(from, limit),
55
- in_processes: MAX_THREADS,
56
- finish: order_pmap_post_processor(from)) do |row|
57
- next unless row['doc']['type']&.casecmp?('Order')
60
+ loop do
61
+ on_merge_processes = ->(_item, index, _result) { @on_merge_processes&.call(from + index) }
62
+ processes = @processes > 1 ? @processes : 0
58
63
 
59
- User.current = Utils.lab_user
60
- yield OrderDTO.new(row['doc']), OpenStruct.new(last_seq: (from || 0) + limit, current_seq: from)
61
- end
62
- end
64
+ orders = read_orders(from, limit)
65
+ break if orders.empty?
63
66
 
64
- def last_seq
65
- return 0 unless File.exist?(last_seq_path)
67
+ Parallel.each(orders, in_processes: processes, finish: on_merge_processes) do |row|
68
+ next unless row['doc']['type']&.casecmp?('Order')
66
69
 
67
- File.open(last_seq_path, File::RDONLY) do |file|
68
- last_seq = file.read&.strip
69
- return last_seq.blank? ? nil : last_seq&.to_i
70
+ User.current = Utils.lab_user
71
+ yield OrderDTO.new(row['doc']), OpenStruct.new(last_seq: (from || 0) + limit, current_seq: from)
72
+ end
73
+
74
+ from += orders.size
70
75
  end
71
76
  end
72
77
 
73
78
  private
74
79
 
75
- def last_seq_path
76
- LIMS_LOG_PATH.join('migration-last-id.dat')
80
+ def read_orders(from, batch_size)
81
+ start_key_param = from ? "&skip=#{from}" : ''
82
+ url = "_all_docs?include_docs=true&limit=#{batch_size}#{start_key_param}"
83
+
84
+ Rails.logger.debug("#{CouchDbMigratorApi}: Pulling orders from LIMS CouchDB: #{url}")
85
+ response = bum.couch_rest :get, url
86
+
87
+ response['rows']
88
+ end
89
+ end
90
+
91
+ class MigrationWorker < Worker
92
+ LOG_FILE_PATH = LIMS_LOG_PATH.join('migration-last-id.dat')
93
+
94
+ attr_reader :rejections
95
+
96
+ def initialize(api_class)
97
+ api = api_class.new(processes: MAX_THREADS, on_merge_processes: method(:save_seq))
98
+ super(api)
77
99
  end
78
100
 
79
- def order_pmap_post_processor(last_seq)
80
- lambda do |item, index, result|
81
- save_last_seq(last_seq + index)
82
- status, reason = result
83
- next unless status == :rejected
101
+ def last_seq
102
+ return 0 unless File.exist?(LOG_FILE_PATH)
84
103
 
85
- (@rejections ||= []) << OpenStruct.new(order: OrderDTO.new(item['doc']), reason: reason)
104
+ File.open(LOG_FILE_PATH, File::RDONLY) do |file|
105
+ last_seq = file.read&.strip
106
+ return last_seq.blank? ? nil : last_seq&.to_i
86
107
  end
87
108
  end
88
109
 
89
- def save_last_seq(last_seq)
90
- return unless last_seq
110
+ private
91
111
 
92
- File.open(last_seq_path, File::WRONLY | File::CREAT, 0o644) do |file|
112
+ def save_seq(last_seq)
113
+ File.open(LOG_FILE_PATH, File::WRONLY | File::CREAT, 0o644) do |file|
93
114
  Rails.logger.debug("Process ##{Parallel.worker_number}: Saving last seq: #{last_seq}")
94
115
  file.flock(File::LOCK_EX)
95
116
  file.write(last_seq.to_s)
@@ -97,39 +118,11 @@ module Lab
97
118
  end
98
119
  end
99
120
 
100
- def save_rejection(order_dto, reason); end
101
-
102
- def read_orders(from, batch_size)
103
- Enumerator.new do |enum|
104
- loop do
105
- start_key_param = from ? "&skip=#{from}" : ''
106
- url = "_all_docs?include_docs=true&limit=#{batch_size}#{start_key_param}"
107
-
108
- Rails.logger.debug("#{MigratorApi}: Pulling orders from LIMS CouchDB: #{url}")
109
- response = bum.couch_rest :get, url
110
-
111
- from ||= 0
112
-
113
- break from if response['rows'].empty?
114
-
115
- response['rows'].each do |row|
116
- enum.yield(row)
117
- end
118
-
119
- from += response['rows'].size
120
- end
121
- end
122
- end
123
- end
124
-
125
- class MigrationWorker < Worker
126
- protected
121
+ def order_rejected(order_dto, reason)
122
+ @rejections ||= []
127
123
 
128
- def last_seq
129
- lims_api.last_seq
124
+ @rejections << OpenStruct.new(order: order_dto, reason: reason)
130
125
  end
131
-
132
- def update_last_seq(_last_seq); end
133
126
  end
134
127
 
135
128
  def self.save_csv(filename, rows:, headers: nil)
@@ -177,8 +170,7 @@ module Lab
177
170
  MIGRATION_LOG_PATH = LIMS_LOG_PATH.join('migration.log')
178
171
 
179
172
  def self.start_migration
180
- log_dir = Rails.root.join('log/lims')
181
- Dir.mkdir(log_dir) unless File.exist?(log_dir)
173
+ Dir.mkdir(LIMS_LOG_PATH) unless File.exist?(LIMS_LOG_PATH)
182
174
 
183
175
  logger = LoggerMultiplexor.new(Logger.new($stdout), MIGRATION_LOG_PATH)
184
176
  logger.level = :debug
@@ -186,12 +178,17 @@ module Lab
186
178
  ActiveRecord::Base.logger = logger
187
179
  # CouchBum.logger = logger
188
180
 
189
- api = MigratorApi.new
190
- worker = MigrationWorker.new(api)
181
+ api_class = case ENV.fetch('MIGRATION_SOURCE', 'couchdb').downcase
182
+ when 'couchdb' then CouchDbMigratorApi
183
+ when 'mysql' then Api::MysqlApi
184
+ else raise "Invalid MIGRATION_SOURCE: #{ENV['MIGRATION_SOURCE']}"
185
+ end
186
+
187
+ worker = MigrationWorker.new(api_class)
191
188
 
192
- worker.pull_orders
189
+ worker.pull_orders(batch_size: 10_000)
193
190
  ensure
194
- api && export_rejections(api.rejections)
191
+ worker && export_rejections(worker.rejections)
195
192
  export_failures
196
193
  end
197
194
  end
@@ -69,9 +69,7 @@ module Lab
69
69
  end
70
70
 
71
71
  def start_date
72
- if self['date_created'].blank?
73
- raise LimsException, 'Order missing created date'
74
- end
72
+ raise LimsException, 'Order missing created date' if self['date_created'].blank?
75
73
 
76
74
  Utils.parse_date(self['date_created'])
77
75
  end
@@ -24,6 +24,8 @@ module Lab
24
24
  sample_type: format_sample_type(serialized_order.specimen.name),
25
25
  sample_status: format_sample_status(serialized_order.specimen.name),
26
26
  sample_statuses: format_sample_status_trail(order),
27
+ test_statuses: format_test_status_trail(order),
28
+ who_order_test: format_orderer(order),
27
29
  districy: current_district, # yes districy [sic]...
28
30
  priority: serialized_order.reason_for_test.name,
29
31
  date_created: serialized_order.order_date,
@@ -75,9 +77,7 @@ module Lab
75
77
  end
76
78
 
77
79
  def format_sample_status_trail(order)
78
- if order.concept_id == ConceptName.find_by_name!('Unknown').concept_id
79
- return []
80
- end
80
+ return [] if order.concept_id == ConceptName.find_by_name!('Unknown').concept_id
81
81
 
82
82
  user = User.find(order.discontinued_by || order.creator)
83
83
  drawn_by = PersonName.find_by_person_id(user.user_id)
@@ -96,6 +96,31 @@ module Lab
96
96
  ]
97
97
  end
98
98
 
99
+ def format_test_status_trail(order)
100
+ order.tests.each_with_object({}) do |test, trail|
101
+ test_name = ConceptName.find_by_concept_id!(test.value_coded).name
102
+ test_name = 'Viral load' if test_name.casecmp?('HIV Viral Load')
103
+
104
+ current_test_trail = trail[test_name] = {}
105
+
106
+ current_test_trail[test.obs_datetime.strftime('%Y%m%d%H%M%S')] = {
107
+ status: 'Drawn',
108
+ updated_by: find_user(test.creator)
109
+ }
110
+
111
+ next unless test.result
112
+
113
+ current_test_trail[test.obs_datetime.strftime('%Y%m%d%H%M%S')] = {
114
+ status: 'Verified',
115
+ updated_by: find_user(test.result.creator)
116
+ }
117
+ end
118
+ end
119
+
120
+ def format_orderer(order)
121
+ find_user(order.creator)
122
+ end
123
+
99
124
  def format_test_results(order)
100
125
  order.tests&.each_with_object({}) do |test, results|
101
126
  next unless test.result
@@ -134,6 +159,20 @@ module Lab
134
159
  def current_facility_name
135
160
  current_health_center.name
136
161
  end
162
+
163
+ def find_user(user_id)
164
+ user = User.find(user_id)
165
+ person_name = PersonName.find_by(person_id: user.person_id)
166
+ phone_number = PersonAttribute.find_by(type: PersonAttributeType.where(name: 'Cell phone number'),
167
+ person_id: user.person_id)
168
+
169
+ {
170
+ first_name: person_name&.given_name,
171
+ last_name: person_name&.family_name,
172
+ phone_number: phone_number&.value,
173
+ id: user.username
174
+ }
175
+ end
137
176
  end
138
177
  end
139
178
  end
@@ -52,21 +52,22 @@ module Lab
52
52
  end
53
53
 
54
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
55
+ str_date = str_date&.to_s
56
+
57
+ raise "Can't parse blank date" if str_date.blank? && fallback_date.blank?
58
58
 
59
59
  return parse_date(fallback_date) if str_date.blank?
60
60
 
61
61
  str_date = str_date.gsub(/^00/, '20').gsub(/^180/, '20')
62
62
 
63
- if str_date.match?(/\d{4}-\d{2}-\d{2}/)
63
+ case str_date
64
+ when /\d{4}-\d{2}-\d{2}/
64
65
  str_date
65
- elsif str_date.match?(/\d{2}-\d{2}-\d{2}/)
66
+ when /\d{2}-\d{2}-\d{2}/
66
67
  Date.strptime(str_date, '%d-%m-%Y').strftime('%Y-%m-%d')
67
- elsif str_date.match?(/(\d{4}\d{2}\d{2})\d+/)
68
+ when /(\d{4}\d{2}\d{2})\d+/
68
69
  Date.strptime(str_date, '%Y%m%d').strftime('%Y-%m-%d')
69
- elsif str_date.match?(%r{\d{2}/\d{2}/\d{4}})
70
+ when %r{\d{2}/\d{2}/\d{4}}
70
71
  str_date.to_date.to_s
71
72
  else
72
73
  Rails.logger.warn("Invalid date: #{str_date}")
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'cgi/util'
4
4
 
5
- require_relative './api'
5
+ require_relative './api/couchdb_api'
6
6
  require_relative './exceptions'
7
7
  require_relative './order_serializer'
8
8
  require_relative './utils'
@@ -26,11 +26,11 @@ module Lab
26
26
  User.current = Utils.lab_user
27
27
 
28
28
  fout.write("Worker ##{Process.pid} started at #{Time.now}")
29
- worker = new(Api.new)
29
+ worker = new(Api::CouchDbApi.new)
30
30
  worker.pull_orders
31
31
  # TODO: Verify that names being pushed to LIMS are of the correct format (ie matching
32
32
  # LIMS naming conventions). Enable pushing when that is done
33
- # worker.push_orders
33
+ worker.push_orders
34
34
  end
35
35
  end
36
36
 
@@ -81,28 +81,34 @@ module Lab
81
81
 
82
82
  ##
83
83
  # Pulls orders from the LIMS queue and writes them to the local database
84
- def pull_orders
84
+ def pull_orders(batch_size: 10_000)
85
85
  logger.info("Retrieving LIMS orders starting from #{last_seq}")
86
86
 
87
- lims_api.consume_orders(from: last_seq, limit: 100) do |order_dto, context|
87
+ lims_api.consume_orders(from: last_seq, limit: batch_size) do |order_dto, context|
88
88
  logger.debug("Retrieved order ##{order_dto[:tracking_number]}: #{order_dto}")
89
89
 
90
90
  patient = find_patient_by_nhid(order_dto[:patient][:id])
91
91
  unless patient
92
92
  logger.debug("Discarding order: Non local patient ##{order_dto[:patient][:id]} on order ##{order_dto[:tracking_number]}")
93
- next [:rejected, "Patient NPID, '#{order_dto[:patient][:id]}', didn't match any local NPIDs"]
93
+ order_rejected(order_dto, "Patient NPID, '#{order_dto[:patient][:id]}', didn't match any local NPIDs")
94
+ next
95
+ end
96
+
97
+ if order_dto[:tests].empty?
98
+ logger.debug("Discarding order: Missing tests on order ##{order_dto[:tracking_number]}")
99
+ order_rejected(order_dto, 'Order is missing tests')
100
+ next
94
101
  end
95
102
 
96
103
  diff = match_patient_demographics(patient, order_dto['patient'])
97
104
  if diff.empty?
98
105
  save_order(patient, order_dto)
106
+ order_saved(order_dto)
99
107
  else
100
108
  save_failed_import(order_dto, 'Demographics not matching', diff)
101
109
  end
102
110
 
103
111
  update_last_seq(context.current_seq)
104
-
105
- [:accepted, "Patient NPID, '#{order_dto[:patient][:id]}', matched"]
106
112
  rescue DuplicateNHID
107
113
  logger.warn("Failed to import order due to duplicate patient NHID: #{order_dto[:patient][:id]}")
108
114
  save_failed_import(order_dto, "Duplicate local patient NHID: #{order_dto[:patient][:id]}")
@@ -134,23 +140,33 @@ module Lab
134
140
  end
135
141
  end
136
142
 
143
+ def order_saved(order_dto); end
144
+
145
+ def order_rejected(order_dto, message); end
146
+
137
147
  private
138
148
 
139
149
  def find_patient_by_nhid(nhid)
140
150
  national_id_type = PatientIdentifierType.where(name: ['National id', 'Old Identification Number'])
141
151
  identifiers = PatientIdentifier.where(type: national_id_type, identifier: nhid)
152
+ .joins('INNER JOIN person ON person.person_id = patient_identifier.patient_id AND person.voided = 0')
142
153
  if identifiers.count.zero?
143
- identifiers = PatientIdentifier.unscoped.where(voided: 1, type: national_id_type, identifier: nhid)
154
+ identifiers = PatientIdentifier.unscoped
155
+ .where(voided: 1, type: national_id_type, identifier: nhid)
156
+ .joins('INNER JOIN person ON person.person_id = patient_identifier.patient_id AND person.voided = 0')
144
157
  end
158
+
159
+ # Joining to person above to ensure that the person is not voided,
160
+ # it was noted at one site that there were some people that were voided
161
+ # upon merging but the patient and patient_identifier was not voided
162
+
145
163
  return nil if identifiers.count.zero?
146
164
 
147
165
  patients = Patient.where(patient_id: identifiers.select(:patient_id))
148
166
  .distinct(:patient_id)
149
167
  .all
150
168
 
151
- if patients.size > 1
152
- raise DuplicateNHID, "Duplicate National Health ID: #{nhid}"
153
- end
169
+ raise DuplicateNHID, "Duplicate National Health ID: #{nhid}" if patients.size > 1
154
170
 
155
171
  patients.first
156
172
  end
@@ -201,10 +217,11 @@ module Lab
201
217
  mapping.update(pulled_at: Time.now)
202
218
  else
203
219
  order = create_order(patient, order_dto)
204
- LimsOrderMapping.create!(lims_id: order_dto[:_id],
205
- order_id: order['id'],
206
- pulled_at: Time.now,
207
- revision: order_dto['_rev'])
220
+ mapping = LimsOrderMapping.create(lims_id: order_dto[:_id],
221
+ order_id: order['id'],
222
+ pulled_at: Time.now,
223
+ revision: order_dto['_rev'])
224
+ byebug unless mapping.errors.empty?
208
225
  end
209
226
 
210
227
  order
@@ -214,9 +231,7 @@ module Lab
214
231
  def create_order(patient, order_dto)
215
232
  logger.debug("Creating order ##{order_dto['_id']}")
216
233
  order = OrdersService.order_test(order_dto.to_order_service_params(patient_id: patient.patient_id))
217
- unless order_dto['test_results'].empty?
218
- update_results(order, order_dto['test_results'])
219
- end
234
+ update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
220
235
 
221
236
  order
222
237
  end
@@ -225,9 +240,7 @@ module Lab
225
240
  logger.debug("Updating order ##{order_dto['_id']}")
226
241
  order = OrdersService.update_order(order_id, order_dto.to_order_service_params(patient_id: patient.patient_id)
227
242
  .merge(force_update: true))
228
- unless order_dto['test_results'].empty?
229
- update_results(order, order_dto['test_results'])
230
- end
243
+ update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
231
244
 
232
245
  order
233
246
  end
@@ -242,6 +255,8 @@ module Lab
242
255
  next
243
256
  end
244
257
 
258
+ next unless test_results['results']
259
+
245
260
  measures = test_results['results'].map do |indicator, value|
246
261
  measure = find_measure(order, indicator, value)
247
262
  next nil unless measure
@@ -250,7 +265,6 @@ module Lab
250
265
  end
251
266
 
252
267
  measures = measures.compact
253
-
254
268
  next if measures.empty?
255
269
 
256
270
  creator = format_result_entered_by(test_results['result_entered_by'])
@@ -87,9 +87,7 @@ module Lab
87
87
  end
88
88
 
89
89
  def validate_measure_params(params)
90
- if params[:value].blank?
91
- raise InvalidParameterError, 'measures.value is required'
92
- end
90
+ raise InvalidParameterError, 'measures.value is required' if params[:value].blank?
93
91
 
94
92
  if params[:indicator]&.[](:concept_id).blank?
95
93
  raise InvalidParameterError, 'measures.indicator.concept_id is required'
data/lib/lab/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lab
4
- VERSION = '0.0.11'
4
+ VERSION = '1.0.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: his_emr_api_lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.11
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Elizabeth Glaser Pediatric Foundation Malawi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-04-29 00:00:00.000000000 Z
11
+ date: 2021-06-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: couchrest
@@ -249,9 +249,11 @@ files:
249
249
  - app/services/lab/accession_number_service.rb
250
250
  - app/services/lab/concepts_service.rb
251
251
  - app/services/lab/labelling_service/order_label.rb
252
- - app/services/lab/lims/api.rb
252
+ - app/services/lab/lims/api/couchdb_api.rb
253
+ - app/services/lab/lims/api/mysql_api.rb
253
254
  - app/services/lab/lims/config.rb
254
255
  - app/services/lab/lims/exceptions.rb
256
+ - app/services/lab/lims/failed_imports.rb
255
257
  - app/services/lab/lims/migrator.rb
256
258
  - app/services/lab/lims/order_dto.rb
257
259
  - app/services/lab/lims/order_serializer.rb
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'couch_bum/couch_bum'
4
-
5
- require_relative './config'
6
-
7
- module Lab
8
- module Lims
9
- ##
10
- # Talk to LIMS like a boss
11
- class Api
12
- attr_reader :bum
13
-
14
- def initialize(config: nil)
15
- config ||= Config.couchdb
16
-
17
- @bum = CouchBum.new(protocol: config['protocol'],
18
- host: config['host'],
19
- port: config['port'],
20
- database: "#{config['prefix']}_order_#{config['suffix']}",
21
- username: config['username'],
22
- password: config['password'])
23
- end
24
-
25
- ##
26
- # Consume orders from the LIMS queue.
27
- #
28
- # Retrieves orders from the LIMS queue and passes each order to
29
- # given block until the queue is empty or connection is terminated
30
- # by calling method +choke+.
31
- def consume_orders(from: 0, limit: 30)
32
- bum.binge_changes(since: from, limit: limit, include_docs: true) do |change|
33
- next unless change['doc']['type']&.casecmp?('Order')
34
-
35
- yield OrderDTO.new(change['doc']), self
36
- end
37
- end
38
-
39
- def create_order(order)
40
- bum.couch_rest :post, '/', order
41
- end
42
-
43
- def update_order(id, order)
44
- bum.couch_rest :put, "/#{id}", order
45
- end
46
- end
47
- end
48
- end