his_emr_api_lab 1.0.1 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +20 -1
- data/app/jobs/lab/update_patient_orders_job.rb +20 -0
- data/app/jobs/lab/void_order_job.rb +18 -0
- data/app/models/lab/lab_order.rb +4 -0
- data/app/serializers/lab/lab_order_serializer.rb +7 -1
- data/app/services/lab/lims/api/rest_api.rb +344 -0
- data/app/services/lab/lims/api/ws_api.rb +121 -0
- data/app/services/lab/lims/config.rb +45 -8
- data/app/services/lab/lims/migrator.rb +12 -9
- data/app/services/lab/lims/order_dto.rb +2 -2
- data/app/services/lab/lims/order_serializer.rb +32 -9
- data/app/services/lab/lims/pull_worker.rb +295 -0
- data/app/services/lab/lims/push_worker.rb +103 -0
- data/app/services/lab/lims/utils.rb +6 -1
- data/app/services/lab/lims/worker.rb +40 -308
- data/app/services/lab/orders_search_service.rb +18 -0
- data/app/services/lab/orders_service.rb +4 -2
- data/db/migrate/20210610095024_fix_numeric_results_value_type.rb +20 -0
- data/lib/lab/version.rb +1 -1
- metadata +23 -3
- data/app/services/lab/lims/failed_imports.rb +0 -34
@@ -0,0 +1,121 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'socket.io-client-simple'
|
4
|
+
|
5
|
+
module Lab
|
6
|
+
module Lims
|
7
|
+
module Api
|
8
|
+
##
|
9
|
+
# Retrieve results from LIMS only through a websocket
|
10
|
+
class WsApi
|
11
|
+
def initialize(config)
|
12
|
+
@config = config
|
13
|
+
@results_queue = []
|
14
|
+
@socket = nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def consume_orders(**_kwargs)
|
18
|
+
loop do
|
19
|
+
results = fetch_results
|
20
|
+
unless results
|
21
|
+
Rails.logger.debug('No results available... Waiting for results...')
|
22
|
+
sleep(Lab::Lims::Config.updates_poll_frequency)
|
23
|
+
next
|
24
|
+
end
|
25
|
+
|
26
|
+
Rails.logger.info("Received result for ##{results['tracking_number']}")
|
27
|
+
order = find_order(results['tracking_number'])
|
28
|
+
next unless order
|
29
|
+
|
30
|
+
Rails.logger.info("Updating result for order ##{order.order_id}")
|
31
|
+
yield make_order_dto(order, results), OpenStruct.new(last_seq: 1)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def initialize_socket
|
38
|
+
Rails.logger.debug('Establishing connection to socket...')
|
39
|
+
socket = SocketIO::Client::Simple.connect(socket_url)
|
40
|
+
socket.on(:connect, &method(:on_socket_connect))
|
41
|
+
socket.on(:disconnect, &method(:on_socket_disconnect))
|
42
|
+
socket.on(:results, &method(:on_results_received))
|
43
|
+
end
|
44
|
+
|
45
|
+
def socket_url
|
46
|
+
@config.fetch('url')
|
47
|
+
end
|
48
|
+
|
49
|
+
def on_socket_connect
|
50
|
+
Rails.logger.debug('Connection to LIMS results socket established...')
|
51
|
+
end
|
52
|
+
|
53
|
+
def on_socket_disconnect
|
54
|
+
Rails.logger.debug('Connection to LIMS results socket lost...')
|
55
|
+
@socket = nil
|
56
|
+
end
|
57
|
+
|
58
|
+
def on_results_received(result)
|
59
|
+
Rails.logger.debug("Received result from LIMS: #{result}")
|
60
|
+
tracking_number = result['tracking_number']
|
61
|
+
|
62
|
+
Rails.logger.debug("Queueing result for order ##{tracking_number}")
|
63
|
+
@results_queue.push(result)
|
64
|
+
end
|
65
|
+
|
66
|
+
def order_exists?(tracking_number)
|
67
|
+
Rails.logger.debug("Looking for order for result ##{tracking_number}")
|
68
|
+
orders = OrdersSearchService.find_orders_without_results
|
69
|
+
.where(accession_number: tracking_number)
|
70
|
+
# The following ensures that the order was previously pushed to LIMS
|
71
|
+
# or was received from LIMS
|
72
|
+
Lab::LimsOrderMapping.where.not(order: orders).exists?
|
73
|
+
end
|
74
|
+
|
75
|
+
def fetch_results
|
76
|
+
loop do
|
77
|
+
@socket ||= initialize_socket
|
78
|
+
|
79
|
+
results = @results_queue.shift
|
80
|
+
return nil unless results
|
81
|
+
|
82
|
+
unless order_exists?(results['tracking_number'])
|
83
|
+
Rails.logger.debug("Ignoring result for order ##{tracking_number}")
|
84
|
+
next
|
85
|
+
end
|
86
|
+
|
87
|
+
return results
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def find_order(lims_id)
|
92
|
+
mapping = Lab::LimsOrderMapping.where(lims_id: lims_id).select(:order_id)
|
93
|
+
Lab::LabOrder.find_by(order_id: mapping)
|
94
|
+
end
|
95
|
+
|
96
|
+
def make_order_dto(order, results)
|
97
|
+
Lab::Lims::OrderSerializer
|
98
|
+
.serialize_order(order)
|
99
|
+
.merge(
|
100
|
+
id: order.accession_number,
|
101
|
+
test_results: {
|
102
|
+
results['test_name'] => {
|
103
|
+
results: results['results'].each_with_object({}) do |measure, formatted_measures|
|
104
|
+
measure_name, measure_value = measure
|
105
|
+
|
106
|
+
formatted_measures[measure_name] = { result_value: measure_value }
|
107
|
+
end,
|
108
|
+
result_date: results['date_updated'],
|
109
|
+
result_entered_by: {
|
110
|
+
first_name: results['who_updated']['first_name'],
|
111
|
+
last_name: results['who_updated']['last_name'],
|
112
|
+
id: results['who_updated']['id_number']
|
113
|
+
}
|
114
|
+
}
|
115
|
+
}
|
116
|
+
)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -5,6 +5,9 @@ module Lab
|
|
5
5
|
##
|
6
6
|
# Load LIMS' configuration files
|
7
7
|
module Config
|
8
|
+
# TODO: Replace this maybe with `Rails.application.configuration.lab.lims`
|
9
|
+
# so that we do not have to directly mess with configuration files.
|
10
|
+
|
8
11
|
class ConfigNotFound < RuntimeError; end
|
9
12
|
|
10
13
|
class << self
|
@@ -12,31 +15,63 @@ module Lab
|
|
12
15
|
# Returns LIMS' couchdb configuration file for the current environment (Rails.env)
|
13
16
|
def couchdb
|
14
17
|
config_path = begin
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
18
|
+
find_config_path('couchdb.yml')
|
19
|
+
rescue ConfigNotFound => e
|
20
|
+
Rails.logger.error("Failed to find default LIMS couchdb config: #{e.message}")
|
21
|
+
find_config_path('couchdb-lims.yml') # This can be placed in HIS-EMR-API/config
|
22
|
+
end
|
20
23
|
|
21
24
|
Rails.logger.debug("Using LIMS couchdb config: #{config_path}")
|
22
25
|
|
23
26
|
YAML.load_file(config_path)[Rails.env]
|
24
27
|
end
|
25
28
|
|
29
|
+
def rest_api
|
30
|
+
@rest_api ||= {
|
31
|
+
protocol: emr_api_application('lims_protocol', 'http'),
|
32
|
+
host: emr_api_application('lims_host'),
|
33
|
+
port: emr_api_application('lims_port'),
|
34
|
+
username: emr_api_application('lims_username'),
|
35
|
+
password: emr_api_application('lims_password')
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
def updates_socket
|
40
|
+
@updates_socket ||= {
|
41
|
+
'url' => emr_api_application('lims_realtime_updates_url')
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
def updates_poll_frequency
|
46
|
+
30 # Seconds
|
47
|
+
end
|
48
|
+
|
26
49
|
##
|
27
50
|
# Returns LIMS' application.yml configuration file
|
28
51
|
def application
|
29
|
-
YAML.load_file(find_config_path('application.yml'))
|
52
|
+
@application ||= YAML.load_file(find_config_path('application.yml'))
|
30
53
|
end
|
31
54
|
|
32
55
|
##
|
33
56
|
# Returns LIMS' database.yml configuration file
|
34
57
|
def database
|
35
|
-
YAML.load_file(find_config_path('database.yml'))
|
58
|
+
@database ||= YAML.load_file(find_config_path('database.yml'))
|
36
59
|
end
|
37
60
|
|
38
61
|
private
|
39
62
|
|
63
|
+
def emr_api_application(param, fallback = nil)
|
64
|
+
@emr_api_application ||= YAML.load_file(Rails.root.join('config', 'application.yml'))
|
65
|
+
|
66
|
+
@emr_api_application.fetch(param) do
|
67
|
+
unless fallback
|
68
|
+
raise ConfigNotFound, "Missing config param: #{param}"
|
69
|
+
end
|
70
|
+
|
71
|
+
fallback
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
40
75
|
##
|
41
76
|
# Looks for a config file in various LIMS installation directories
|
42
77
|
#
|
@@ -48,7 +83,9 @@ module Lab
|
|
48
83
|
Rails.root.parent.join("nlims_controller/config/#{filename}")
|
49
84
|
]
|
50
85
|
|
51
|
-
|
86
|
+
if filename == 'couchdb.yml'
|
87
|
+
paths = [Rails.root.join('config/lims-couchdb.yml'), *paths]
|
88
|
+
end
|
52
89
|
|
53
90
|
paths.each do |path|
|
54
91
|
Rails.logger.debug("Looking for LIMS couchdb config at: #{path}")
|
@@ -29,8 +29,11 @@ require 'lab/lab_test'
|
|
29
29
|
require 'lab/lims_order_mapping'
|
30
30
|
require 'lab/lims_failed_import'
|
31
31
|
|
32
|
+
require_relative './api/couchdb_api'
|
32
33
|
require_relative './config'
|
33
|
-
require_relative './
|
34
|
+
require_relative './pull_worker'
|
35
|
+
require_relative './utils'
|
36
|
+
|
34
37
|
require_relative '../orders_service'
|
35
38
|
require_relative '../results_service'
|
36
39
|
require_relative '../tests_service'
|
@@ -46,7 +49,7 @@ module Lab
|
|
46
49
|
module Migrator
|
47
50
|
MAX_THREADS = ENV.fetch('MIGRATION_WORKERS', 6).to_i
|
48
51
|
|
49
|
-
class CouchDbMigratorApi < Api::CouchDbApi
|
52
|
+
class CouchDbMigratorApi < Lab::Lims::Api::CouchDbApi
|
50
53
|
def initialize(*args, processes: 1, on_merge_processes: nil, **kwargs)
|
51
54
|
super(*args, **kwargs)
|
52
55
|
|
@@ -88,8 +91,8 @@ module Lab
|
|
88
91
|
end
|
89
92
|
end
|
90
93
|
|
91
|
-
class MigrationWorker <
|
92
|
-
LOG_FILE_PATH = LIMS_LOG_PATH.join('migration-last-id.dat')
|
94
|
+
class MigrationWorker < PullWorker
|
95
|
+
LOG_FILE_PATH = Utils::LIMS_LOG_PATH.join('migration-last-id.dat')
|
93
96
|
|
94
97
|
attr_reader :rejections
|
95
98
|
|
@@ -132,7 +135,8 @@ module Lab
|
|
132
135
|
end
|
133
136
|
end
|
134
137
|
|
135
|
-
|
138
|
+
# NOTE: LIMS_LOG_PATH below is defined in worker.rb
|
139
|
+
MIGRATION_REJECTIONS_CSV_PATH = Utils::LIMS_LOG_PATH.join('migration-rejections.csv')
|
136
140
|
|
137
141
|
def self.export_rejections(rejections)
|
138
142
|
headers = ['doc_id', 'Accession number', 'NHID', 'First name', 'Last name', 'Reason']
|
@@ -150,7 +154,7 @@ module Lab
|
|
150
154
|
save_csv(MIGRATION_REJECTIONS_CSV_PATH, headers: headers, rows: rows)
|
151
155
|
end
|
152
156
|
|
153
|
-
MIGRATION_FAILURES_CSV_PATH = LIMS_LOG_PATH.join('migration-failures.csv')
|
157
|
+
MIGRATION_FAILURES_CSV_PATH = Utils::LIMS_LOG_PATH.join('migration-failures.csv')
|
154
158
|
|
155
159
|
def self.export_failures
|
156
160
|
headers = ['doc_id', 'Accession number', 'NHID', 'Reason', 'Difference']
|
@@ -167,10 +171,10 @@ module Lab
|
|
167
171
|
save_csv(MIGRATION_FAILURES_CSV_PATH, headers: headers, rows: rows)
|
168
172
|
end
|
169
173
|
|
170
|
-
MIGRATION_LOG_PATH = LIMS_LOG_PATH.join('migration.log')
|
174
|
+
MIGRATION_LOG_PATH = Utils::LIMS_LOG_PATH.join('migration.log')
|
171
175
|
|
172
176
|
def self.start_migration
|
173
|
-
Dir.mkdir(LIMS_LOG_PATH) unless File.exist?(LIMS_LOG_PATH)
|
177
|
+
Dir.mkdir(Utils::LIMS_LOG_PATH) unless File.exist?(Utils::LIMS_LOG_PATH)
|
174
178
|
|
175
179
|
logger = LoggerMultiplexor.new(Logger.new($stdout), MIGRATION_LOG_PATH)
|
176
180
|
logger.level = :debug
|
@@ -185,7 +189,6 @@ module Lab
|
|
185
189
|
end
|
186
190
|
|
187
191
|
worker = MigrationWorker.new(api_class)
|
188
|
-
|
189
192
|
worker.pull_orders(batch_size: 10_000)
|
190
193
|
ensure
|
191
194
|
worker && export_rejections(worker.rejections)
|
@@ -83,9 +83,9 @@ module Lab
|
|
83
83
|
|
84
84
|
# Translates a LIMS sample priority to a concept_id
|
85
85
|
def reason_for_test
|
86
|
-
return unknown_concept.concept_id unless self['
|
86
|
+
return unknown_concept.concept_id unless self['priority']
|
87
87
|
|
88
|
-
ConceptName.find_by_name!(self['
|
88
|
+
ConceptName.find_by_name!(self['priority']).concept_id
|
89
89
|
end
|
90
90
|
|
91
91
|
def lab_program
|
@@ -1,5 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative './config'
|
3
4
|
require_relative './order_dto'
|
4
5
|
require_relative './utils'
|
5
6
|
|
@@ -12,9 +13,9 @@ module Lab
|
|
12
13
|
include Utils
|
13
14
|
|
14
15
|
def serialize_order(order)
|
15
|
-
serialized_order = Utils.structify(Lab::LabOrderSerializer.serialize_order(order))
|
16
|
+
serialized_order = Lims::Utils.structify(Lab::LabOrderSerializer.serialize_order(order))
|
16
17
|
|
17
|
-
OrderDTO.new(
|
18
|
+
Lims::OrderDTO.new(
|
18
19
|
tracking_number: serialized_order.accession_number,
|
19
20
|
sending_facility: current_facility_name,
|
20
21
|
receiving_facility: serialized_order.target_lab,
|
@@ -69,7 +70,11 @@ module Lab
|
|
69
70
|
end
|
70
71
|
|
71
72
|
def format_sample_type(name)
|
72
|
-
name.casecmp?('Unknown')
|
73
|
+
return 'not_specified' if name.casecmp?('Unknown')
|
74
|
+
|
75
|
+
return 'CSF' if name.casecmp?('Cerebrospinal Fluid')
|
76
|
+
|
77
|
+
name.titleize
|
73
78
|
end
|
74
79
|
|
75
80
|
def format_sample_status(name)
|
@@ -77,7 +82,9 @@ module Lab
|
|
77
82
|
end
|
78
83
|
|
79
84
|
def format_sample_status_trail(order)
|
80
|
-
|
85
|
+
if order.concept_id == ConceptName.find_by_name!('Unknown').concept_id
|
86
|
+
return []
|
87
|
+
end
|
81
88
|
|
82
89
|
user = User.find(order.discontinued_by || order.creator)
|
83
90
|
drawn_by = PersonName.find_by_person_id(user.user_id)
|
@@ -97,7 +104,9 @@ module Lab
|
|
97
104
|
end
|
98
105
|
|
99
106
|
def format_test_status_trail(order)
|
100
|
-
order.tests.
|
107
|
+
tests = order.voided.zero? ? order.tests : Lab::LabOrderSerializer.voided_tests(order)
|
108
|
+
|
109
|
+
tests.each_with_object({}) do |test, trail|
|
101
110
|
test_name = format_test_name(ConceptName.find_by_concept_id!(test.value_coded).name)
|
102
111
|
|
103
112
|
current_test_trail = trail[test_name] = {}
|
@@ -107,6 +116,13 @@ module Lab
|
|
107
116
|
updated_by: find_user(test.creator)
|
108
117
|
}
|
109
118
|
|
119
|
+
unless test.voided.zero?
|
120
|
+
current_test_trail[test.date_voided.strftime('%Y%m%d%H%M%S')] = {
|
121
|
+
status: 'Voided',
|
122
|
+
updated_by: find_user(test.voided_by)
|
123
|
+
}
|
124
|
+
end
|
125
|
+
|
110
126
|
next unless test.result
|
111
127
|
|
112
128
|
current_test_trail[test.obs_datetime.strftime('%Y%m%d%H%M%S')] = {
|
@@ -122,7 +138,10 @@ module Lab
|
|
122
138
|
|
123
139
|
def format_test_results(order)
|
124
140
|
order.tests&.each_with_object({}) do |test, results|
|
125
|
-
next
|
141
|
+
next if test.result.nil? || test.result.empty?
|
142
|
+
|
143
|
+
test_creator = User.find(Observation.find(test.result.first.id).creator)
|
144
|
+
test_creator_name = PersonName.find_by_person_id(test_creator.person_id)
|
126
145
|
|
127
146
|
results[format_test_name(test.name)] = {
|
128
147
|
results: test.result.each_with_object({}) do |measure, measures|
|
@@ -131,7 +150,11 @@ module Lab
|
|
131
150
|
}
|
132
151
|
end,
|
133
152
|
result_date: test.result.first&.date,
|
134
|
-
result_entered_by: {
|
153
|
+
result_entered_by: {
|
154
|
+
first_name: test_creator_name&.given_name,
|
155
|
+
last_name: test_creator_name&.family_name,
|
156
|
+
id: test_creator.username
|
157
|
+
}
|
135
158
|
}
|
136
159
|
end
|
137
160
|
end
|
@@ -139,7 +162,7 @@ module Lab
|
|
139
162
|
def format_test_name(test_name)
|
140
163
|
return 'Viral Load' if test_name.casecmp?('HIV Viral load')
|
141
164
|
|
142
|
-
return 'TB' if test_name.
|
165
|
+
return 'TB' if test_name.casecmp?('TB Program')
|
143
166
|
|
144
167
|
test_name.titleize
|
145
168
|
end
|
@@ -165,7 +188,7 @@ module Lab
|
|
165
188
|
return district if district
|
166
189
|
|
167
190
|
GlobalProperty.create(property: 'current_health_center_district',
|
168
|
-
property_value: Config.application['district'],
|
191
|
+
property_value: Lims::Config.application['district'],
|
169
192
|
uuid: SecureRandom.uuid)
|
170
193
|
|
171
194
|
Config.application['district']
|
@@ -0,0 +1,295 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lab
|
4
|
+
module Lims
|
5
|
+
class PullWorker
|
6
|
+
attr_reader :lims_api
|
7
|
+
|
8
|
+
include Utils # for logger
|
9
|
+
|
10
|
+
LIMS_LOG_PATH = Rails.root.join('log', 'lims')
|
11
|
+
|
12
|
+
def initialize(lims_api)
|
13
|
+
@lims_api = lims_api
|
14
|
+
end
|
15
|
+
|
16
|
+
##
|
17
|
+
# Pulls orders from the LIMS queue and writes them to the local database
|
18
|
+
def pull_orders(batch_size: 10_000, **kwargs)
|
19
|
+
logger.info("Retrieving LIMS orders starting from #{last_seq}")
|
20
|
+
|
21
|
+
lims_api.consume_orders(from: last_seq, limit: batch_size, **kwargs) do |order_dto, context|
|
22
|
+
logger.debug("Retrieved order ##{order_dto[:tracking_number]}: #{order_dto}")
|
23
|
+
|
24
|
+
patient = find_patient_by_nhid(order_dto[:patient][:id])
|
25
|
+
unless patient
|
26
|
+
logger.debug("Discarding order: Non local patient ##{order_dto[:patient][:id]} on order ##{order_dto[:tracking_number]}")
|
27
|
+
order_rejected(order_dto, "Patient NPID, '#{order_dto[:patient][:id]}', didn't match any local NPIDs")
|
28
|
+
next
|
29
|
+
end
|
30
|
+
|
31
|
+
if order_dto[:tests].empty?
|
32
|
+
logger.debug("Discarding order: Missing tests on order ##{order_dto[:tracking_number]}")
|
33
|
+
order_rejected(order_dto, 'Order is missing tests')
|
34
|
+
next
|
35
|
+
end
|
36
|
+
|
37
|
+
diff = match_patient_demographics(patient, order_dto['patient'])
|
38
|
+
if diff.empty?
|
39
|
+
save_order(patient, order_dto)
|
40
|
+
order_saved(order_dto)
|
41
|
+
else
|
42
|
+
save_failed_import(order_dto, 'Demographics not matching', diff)
|
43
|
+
end
|
44
|
+
|
45
|
+
update_last_seq(context.current_seq)
|
46
|
+
rescue DuplicateNHID
|
47
|
+
logger.warn("Failed to import order due to duplicate patient NHID: #{order_dto[:patient][:id]}")
|
48
|
+
save_failed_import(order_dto, "Duplicate local patient NHID: #{order_dto[:patient][:id]}")
|
49
|
+
rescue MissingAccessionNumber
|
50
|
+
logger.warn("Failed to import order due to missing accession number: #{order_dto[:_id]}")
|
51
|
+
save_failed_import(order_dto, 'Order missing tracking number')
|
52
|
+
rescue LimsException => e
|
53
|
+
logger.warn("Failed to import order due to #{e.class} - #{e.message}")
|
54
|
+
save_failed_import(order_dto, e.message)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
protected
|
59
|
+
|
60
|
+
def order_saved(order_dto); end
|
61
|
+
|
62
|
+
def order_rejected(order_dto, message); end
|
63
|
+
|
64
|
+
def last_seq
|
65
|
+
File.open(last_seq_path, File::RDONLY | File::CREAT, 0o644) do |fin|
|
66
|
+
data = fin.read&.strip
|
67
|
+
return nil if data.blank?
|
68
|
+
|
69
|
+
return data
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def update_last_seq(last_seq)
|
74
|
+
File.open(last_seq_path, File::WRONLY | File::CREAT, 0o644) do |fout|
|
75
|
+
fout.flock(File::LOCK_EX)
|
76
|
+
|
77
|
+
fout.write(last_seq.to_s)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def find_patient_by_nhid(nhid)
|
84
|
+
national_id_type = PatientIdentifierType.where(name: ['National id', 'Old Identification Number'])
|
85
|
+
identifiers = PatientIdentifier.where(type: national_id_type, identifier: nhid)
|
86
|
+
.joins('INNER JOIN person ON person.person_id = patient_identifier.patient_id AND person.voided = 0')
|
87
|
+
if identifiers.count.zero?
|
88
|
+
identifiers = PatientIdentifier.unscoped
|
89
|
+
.where(voided: 1, type: national_id_type, identifier: nhid)
|
90
|
+
.joins('INNER JOIN person ON person.person_id = patient_identifier.patient_id AND person.voided = 0')
|
91
|
+
end
|
92
|
+
|
93
|
+
# Joining to person above to ensure that the person is not voided,
|
94
|
+
# it was noted at one site that there were some people that were voided
|
95
|
+
# upon merging but the patient and patient_identifier was not voided
|
96
|
+
|
97
|
+
return nil if identifiers.count.zero?
|
98
|
+
|
99
|
+
patients = Patient.where(patient_id: identifiers.select(:patient_id))
|
100
|
+
.distinct(:patient_id)
|
101
|
+
.all
|
102
|
+
|
103
|
+
if patients.size > 1
|
104
|
+
raise DuplicateNHID, "Duplicate National Health ID: #{nhid}"
|
105
|
+
end
|
106
|
+
|
107
|
+
patients.first
|
108
|
+
end
|
109
|
+
|
110
|
+
##
|
111
|
+
# Matches a local patient's demographics to a LIMS patient's demographics
|
112
|
+
def match_patient_demographics(local_patient, lims_patient)
|
113
|
+
diff = {}
|
114
|
+
person = Person.find(local_patient.id)
|
115
|
+
person_name = PersonName.find_by_person_id(local_patient.id)
|
116
|
+
|
117
|
+
unless (person.gender.blank? && lims_patient['gender'].blank?)\
|
118
|
+
|| person.gender&.first&.casecmp?(lims_patient['gender']&.first)
|
119
|
+
diff[:gender] = { local: person.gender, lims: lims_patient['gender'] }
|
120
|
+
end
|
121
|
+
|
122
|
+
unless names_match?(person_name&.given_name, lims_patient['first_name'])
|
123
|
+
diff[:given_name] = { local: person_name&.given_name, lims: lims_patient['first_name'] }
|
124
|
+
end
|
125
|
+
|
126
|
+
unless names_match?(person_name&.family_name, lims_patient['last_name'])
|
127
|
+
diff[:family_name] = { local: person_name&.family_name, lims: lims_patient['last_name'] }
|
128
|
+
end
|
129
|
+
|
130
|
+
diff
|
131
|
+
end
|
132
|
+
|
133
|
+
def names_match?(name1, name2)
|
134
|
+
name1 = name1&.gsub(/'/, '')&.strip
|
135
|
+
name2 = name2&.gsub(/'/, '')&.strip
|
136
|
+
|
137
|
+
return true if name1.blank? && name2.blank?
|
138
|
+
|
139
|
+
return false if name1.blank? || name2.blank?
|
140
|
+
|
141
|
+
name1.casecmp?(name2)
|
142
|
+
end
|
143
|
+
|
144
|
+
def save_order(patient, order_dto)
|
145
|
+
raise MissingAccessionNumber if order_dto[:tracking_number].blank?
|
146
|
+
|
147
|
+
logger.info("Importing LIMS order ##{order_dto[:tracking_number]}")
|
148
|
+
mapping = find_order_mapping_by_lims_id(order_dto[:_id])
|
149
|
+
|
150
|
+
ActiveRecord::Base.transaction do
|
151
|
+
if mapping
|
152
|
+
order = update_order(patient, mapping.order_id, order_dto)
|
153
|
+
mapping.update(pulled_at: Time.now)
|
154
|
+
else
|
155
|
+
order = create_order(patient, order_dto)
|
156
|
+
mapping = LimsOrderMapping.create(lims_id: order_dto[:_id],
|
157
|
+
order_id: order['id'],
|
158
|
+
pulled_at: Time.now,
|
159
|
+
revision: order_dto['_rev'])
|
160
|
+
end
|
161
|
+
|
162
|
+
order
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def create_order(patient, order_dto)
|
167
|
+
logger.debug("Creating order ##{order_dto['_id']}")
|
168
|
+
order = OrdersService.order_test(order_dto.to_order_service_params(patient_id: patient.patient_id))
|
169
|
+
unless order_dto['test_results'].empty?
|
170
|
+
update_results(order, order_dto['test_results'])
|
171
|
+
end
|
172
|
+
|
173
|
+
order
|
174
|
+
end
|
175
|
+
|
176
|
+
def update_order(patient, order_id, order_dto)
|
177
|
+
logger.debug("Updating order ##{order_dto['_id']}")
|
178
|
+
order = OrdersService.update_order(order_id, order_dto.to_order_service_params(patient_id: patient.patient_id)
|
179
|
+
.merge(force_update: true))
|
180
|
+
unless order_dto['test_results'].empty?
|
181
|
+
update_results(order, order_dto['test_results'])
|
182
|
+
end
|
183
|
+
|
184
|
+
order
|
185
|
+
end
|
186
|
+
|
187
|
+
def update_results(order, lims_results)
|
188
|
+
logger.debug("Updating results for order ##{order[:accession_number]}: #{lims_results}")
|
189
|
+
|
190
|
+
lims_results.each do |test_name, test_results|
|
191
|
+
test = find_test(order['id'], test_name)
|
192
|
+
unless test
|
193
|
+
logger.warn("Couldn't find test, #{test_name}, in order ##{order[:id]}")
|
194
|
+
next
|
195
|
+
end
|
196
|
+
|
197
|
+
next unless test_results['results']
|
198
|
+
|
199
|
+
measures = test_results['results'].map do |indicator, value|
|
200
|
+
measure = find_measure(order, indicator, value)
|
201
|
+
next nil unless measure
|
202
|
+
|
203
|
+
measure
|
204
|
+
end
|
205
|
+
|
206
|
+
measures = measures.compact
|
207
|
+
next if measures.empty?
|
208
|
+
|
209
|
+
creator = format_result_entered_by(test_results['result_entered_by'])
|
210
|
+
|
211
|
+
ResultsService.create_results(test.id, provider_id: User.current.person_id,
|
212
|
+
date: Utils.parse_date(test_results['date_result_entered'], order[:order_date].to_s),
|
213
|
+
comments: "LIMS import: Entered by: #{creator}",
|
214
|
+
measures: measures)
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def find_test(order_id, test_name)
|
219
|
+
test_name = Utils.translate_test_name(test_name)
|
220
|
+
test_concept = Utils.find_concept_by_name(test_name)
|
221
|
+
raise "Unknown test name, #{test_name}!" unless test_concept
|
222
|
+
|
223
|
+
LabTest.find_by(order_id: order_id, value_coded: test_concept.concept_id)
|
224
|
+
end
|
225
|
+
|
226
|
+
def find_measure(_order, indicator_name, value)
|
227
|
+
indicator = Utils.find_concept_by_name(indicator_name)
|
228
|
+
unless indicator
|
229
|
+
logger.warn("Result indicator #{indicator_name} not found in concepts list")
|
230
|
+
return nil
|
231
|
+
end
|
232
|
+
|
233
|
+
value_modifier, value, value_type = parse_lims_result_value(value)
|
234
|
+
return nil if value.blank?
|
235
|
+
|
236
|
+
ActiveSupport::HashWithIndifferentAccess.new(
|
237
|
+
indicator: { concept_id: indicator.concept_id },
|
238
|
+
value_type: value_type,
|
239
|
+
value: value_type == 'numeric' ? value.to_f : value,
|
240
|
+
value_modifier: value_modifier.blank? ? '=' : value_modifier
|
241
|
+
)
|
242
|
+
end
|
243
|
+
|
244
|
+
def parse_lims_result_value(value)
|
245
|
+
value = value['result_value']&.strip
|
246
|
+
return nil, nil, nil if value.blank?
|
247
|
+
|
248
|
+
match = value&.match(/^(>|=|<|<=|>=)(.*)$/)
|
249
|
+
return nil, value, guess_result_datatype(value) unless match
|
250
|
+
|
251
|
+
[match[1], match[2].strip, guess_result_datatype(match[2])]
|
252
|
+
end
|
253
|
+
|
254
|
+
def guess_result_datatype(result)
|
255
|
+
return 'numeric' if result.strip.match?(/^[+-]?((\d+(\.\d+)?)|\.\d+)$/)
|
256
|
+
|
257
|
+
'text'
|
258
|
+
end
|
259
|
+
|
260
|
+
def format_result_entered_by(result_entered_by)
|
261
|
+
first_name = result_entered_by['first_name']
|
262
|
+
last_name = result_entered_by['last_name']
|
263
|
+
phone_number = result_entered_by['phone_number']
|
264
|
+
id = result_entered_by['id'] # Looks like a user_id of some sort
|
265
|
+
|
266
|
+
"#{id}:#{first_name} #{last_name}:#{phone_number}"
|
267
|
+
end
|
268
|
+
|
269
|
+
def save_failed_import(order_dto, reason, diff = nil)
|
270
|
+
logger.info("Failed to import LIMS order ##{order_dto[:tracking_number]} due to '#{reason}'")
|
271
|
+
LimsFailedImport.create!(lims_id: order_dto[:_id],
|
272
|
+
tracking_number: order_dto[:tracking_number],
|
273
|
+
patient_nhid: order_dto[:patient][:id],
|
274
|
+
reason: reason,
|
275
|
+
diff: diff&.to_json)
|
276
|
+
end
|
277
|
+
|
278
|
+
def last_seq_path
|
279
|
+
LIMS_LOG_PATH.join('last_seq.dat')
|
280
|
+
end
|
281
|
+
|
282
|
+
def find_order_mapping_by_lims_id(lims_id)
|
283
|
+
mapping = Lab::LimsOrderMapping.find_by(lims_id: lims_id)
|
284
|
+
return nil unless mapping
|
285
|
+
|
286
|
+
if Lab::LabOrder.where(order_id: mapping.order_id).exists?
|
287
|
+
return mapping
|
288
|
+
end
|
289
|
+
|
290
|
+
mapping.destroy
|
291
|
+
nil
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|