his_emr_api_lab 1.0.4 → 1.1.2
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.
- checksums.yaml +4 -4
- data/README.md +20 -1
- data/app/jobs/lab/update_patient_orders_job.rb +33 -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 +405 -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 +10 -3
- data/app/services/lab/lims/order_serializer.rb +32 -8
- data/app/services/lab/lims/pull_worker.rb +295 -0
- data/app/services/lab/lims/push_worker.rb +104 -0
- data/app/services/lab/lims/utils.rb +6 -1
- data/app/services/lab/lims/worker.rb +40 -317
- data/app/services/lab/metadata.rb +1 -0
- data/app/services/lab/orders_search_service.rb +20 -0
- data/app/services/lab/orders_service.rb +35 -9
- data/lib/lab/version.rb +1 -1
- data/lib/tasks/loaders/data/reasons-for-test.csv +1 -0
- data/lib/tasks/loaders/data/tests.csv +0 -2
- metadata +22 -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)
|
@@ -22,7 +22,7 @@ module Lab
|
|
22
22
|
date: start_date,
|
23
23
|
target_lab: facility_name(self['receiving_facility']),
|
24
24
|
order_location: facility_name(self['sending_facility']),
|
25
|
-
|
25
|
+
reason_for_test_id: reason_for_test
|
26
26
|
)
|
27
27
|
end
|
28
28
|
|
@@ -69,7 +69,9 @@ module Lab
|
|
69
69
|
end
|
70
70
|
|
71
71
|
def start_date
|
72
|
-
|
72
|
+
if self['date_created'].blank?
|
73
|
+
raise LimsException, 'Order missing created date'
|
74
|
+
end
|
73
75
|
|
74
76
|
Utils.parse_date(self['date_created'])
|
75
77
|
end
|
@@ -85,7 +87,12 @@ module Lab
|
|
85
87
|
def reason_for_test
|
86
88
|
return unknown_concept.concept_id unless self['priority']
|
87
89
|
|
88
|
-
|
90
|
+
name = case self['priority']
|
91
|
+
when %r{Reapet / Missing}i then 'Repeat / Missing'
|
92
|
+
else self['priority']
|
93
|
+
end
|
94
|
+
|
95
|
+
ConceptName.find_by_name!(name).concept_id
|
89
96
|
end
|
90
97
|
|
91
98
|
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,10 @@ 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(
|
19
|
+
_id: Lab::LimsOrderMapping.find_by(order: order)&.lims_id || serialized_order.accession_number,
|
18
20
|
tracking_number: serialized_order.accession_number,
|
19
21
|
sending_facility: current_facility_name,
|
20
22
|
receiving_facility: serialized_order.target_lab,
|
@@ -69,7 +71,11 @@ module Lab
|
|
69
71
|
end
|
70
72
|
|
71
73
|
def format_sample_type(name)
|
72
|
-
name.casecmp?('Unknown')
|
74
|
+
return 'not_specified' if name.casecmp?('Unknown')
|
75
|
+
|
76
|
+
return 'CSF' if name.casecmp?('Cerebrospinal Fluid')
|
77
|
+
|
78
|
+
name.titleize
|
73
79
|
end
|
74
80
|
|
75
81
|
def format_sample_status(name)
|
@@ -77,7 +83,9 @@ module Lab
|
|
77
83
|
end
|
78
84
|
|
79
85
|
def format_sample_status_trail(order)
|
80
|
-
|
86
|
+
if order.concept_id == ConceptName.find_by_name!('Unknown').concept_id
|
87
|
+
return []
|
88
|
+
end
|
81
89
|
|
82
90
|
user = User.find(order.discontinued_by || order.creator)
|
83
91
|
drawn_by = PersonName.find_by_person_id(user.user_id)
|
@@ -97,7 +105,9 @@ module Lab
|
|
97
105
|
end
|
98
106
|
|
99
107
|
def format_test_status_trail(order)
|
100
|
-
order.tests.
|
108
|
+
tests = order.voided.zero? ? order.tests : Lab::LabOrderSerializer.voided_tests(order)
|
109
|
+
|
110
|
+
tests.each_with_object({}) do |test, trail|
|
101
111
|
test_name = format_test_name(ConceptName.find_by_concept_id!(test.value_coded).name)
|
102
112
|
|
103
113
|
current_test_trail = trail[test_name] = {}
|
@@ -107,6 +117,13 @@ module Lab
|
|
107
117
|
updated_by: find_user(test.creator)
|
108
118
|
}
|
109
119
|
|
120
|
+
unless test.voided.zero?
|
121
|
+
current_test_trail[test.date_voided.strftime('%Y%m%d%H%M%S')] = {
|
122
|
+
status: 'Voided',
|
123
|
+
updated_by: find_user(test.voided_by)
|
124
|
+
}
|
125
|
+
end
|
126
|
+
|
110
127
|
next unless test.result
|
111
128
|
|
112
129
|
current_test_trail[test.obs_datetime.strftime('%Y%m%d%H%M%S')] = {
|
@@ -122,7 +139,10 @@ module Lab
|
|
122
139
|
|
123
140
|
def format_test_results(order)
|
124
141
|
order.tests&.each_with_object({}) do |test, results|
|
125
|
-
next
|
142
|
+
next if test.result.nil? || test.result.empty?
|
143
|
+
|
144
|
+
test_creator = User.find(Observation.find(test.result.first.id).creator)
|
145
|
+
test_creator_name = PersonName.find_by_person_id(test_creator.person_id)
|
126
146
|
|
127
147
|
results[format_test_name(test.name)] = {
|
128
148
|
results: test.result.each_with_object({}) do |measure, measures|
|
@@ -131,7 +151,11 @@ module Lab
|
|
131
151
|
}
|
132
152
|
end,
|
133
153
|
result_date: test.result.first&.date,
|
134
|
-
result_entered_by: {
|
154
|
+
result_entered_by: {
|
155
|
+
first_name: test_creator_name&.given_name,
|
156
|
+
last_name: test_creator_name&.family_name,
|
157
|
+
id: test_creator.username
|
158
|
+
}
|
135
159
|
}
|
136
160
|
end
|
137
161
|
end
|
@@ -165,7 +189,7 @@ module Lab
|
|
165
189
|
return district if district
|
166
190
|
|
167
191
|
GlobalProperty.create(property: 'current_health_center_district',
|
168
|
-
property_value: Config.application['district'],
|
192
|
+
property_value: Lims::Config.application['district'],
|
169
193
|
uuid: SecureRandom.uuid)
|
170
194
|
|
171
195
|
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 if test.result || test_results['results'].blank?
|
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
|