his_emr_api_lab 2.4.8.pre.alpha → 2.4.9
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/app/controllers/lab/orders_controller.rb +2 -2
- data/app/models/lab/lab_order.rb +12 -0
- data/app/services/lab/lims/exceptions.rb +2 -0
- data/app/services/lab/lims/pull_worker.rb +91 -26
- data/app/services/lab/lims/utils.rb +40 -14
- data/app/services/lab/order_location_resolver.rb +2 -0
- data/app/services/lab/orders_service.rb +15 -2
- data/app/services/lab/user_service.rb +5 -0
- data/lib/lab/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f0e2b1fd0f7372571d684a23a529651631e55a271d1355aa1d12f8b7d2a85d5
|
|
4
|
+
data.tar.gz: 6509d471a8f57e1108430e3d6e08a4308b5bca2e0eb386d1088534f0c0863856
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2500b6aa2b1cc75bc8476a4ed68576faf0156cc7f836b7fd3039606db135c05672f957fe4188d69f994b9fc54c952fb39974dcd398dee0376af32e78b5f9f0ab
|
|
7
|
+
data.tar.gz: f74c867864d8c77e8b73ec31b8ac0dd82d5b6d77d4c6152d32cbf3c010e909d24c844839153d4670f14bcfaa9db1e45de6f5d767176d70b262321eec8603b9f4
|
|
@@ -22,8 +22,8 @@ module Lab
|
|
|
22
22
|
|
|
23
23
|
def update
|
|
24
24
|
specimen = params.require(:specimen).slice(:concept_id)
|
|
25
|
-
order = OrdersService.update_order(params[:id], specimen:, force_update: params[:force_update])
|
|
26
|
-
|
|
25
|
+
order = OrdersService.update_order(params[:id], specimen:, force_update: params[:force_update], date: params[:date])
|
|
26
|
+
|
|
27
27
|
begin
|
|
28
28
|
Lab::PushOrderJob.perform_later(order.fetch(:order_id))
|
|
29
29
|
rescue StandardError => e
|
data/app/models/lab/lab_order.rb
CHANGED
|
@@ -66,6 +66,10 @@ module Lab
|
|
|
66
66
|
.where.not(concept_id: ConceptName.where(name: 'Tests ordered').select(:concept_id))
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# Only checked when start_date is set or changed, so existing future-dated
|
|
70
|
+
# orders can still be voided or updated during clean-up.
|
|
71
|
+
validate :start_date_cannot_be_in_the_future, if: :will_save_change_to_start_date?
|
|
72
|
+
|
|
69
73
|
scope :drawn, -> { where.not(concept_id: ConceptName.where(name: 'Unknown').select(:concept_id)) }
|
|
70
74
|
scope :not_drawn, -> { where(concept_id: ConceptName.where(name: 'Unknown').select(:concept_id)) }
|
|
71
75
|
|
|
@@ -78,5 +82,13 @@ module Lab
|
|
|
78
82
|
:comment_to_fulfiller,
|
|
79
83
|
:tests)
|
|
80
84
|
end
|
|
85
|
+
|
|
86
|
+
# Allows up to tomorrow to cover timezone differences, matching
|
|
87
|
+
# Lab::Lims::Utils.parse_date.
|
|
88
|
+
def start_date_cannot_be_in_the_future
|
|
89
|
+
return if start_date.blank? || start_date.to_date <= Date.current + 1.day
|
|
90
|
+
|
|
91
|
+
errors.add(:start_date, 'cannot be in the future')
|
|
92
|
+
end
|
|
81
93
|
end
|
|
82
94
|
end
|
|
@@ -9,6 +9,7 @@ module Lab
|
|
|
9
9
|
class UnknownSpecimenType < LimsException; end
|
|
10
10
|
class UnknownTestType < LimsException; end
|
|
11
11
|
class ValidationUnavailable < LimsException; end
|
|
12
|
+
class InvalidDate < LimsException; end
|
|
12
13
|
end
|
|
13
14
|
end
|
|
14
15
|
end
|
|
@@ -21,5 +22,6 @@ module Lab
|
|
|
21
22
|
class UnknownSpecimenType < LimsException; end
|
|
22
23
|
class UnknownTestType < LimsException; end
|
|
23
24
|
class ValidationUnavailable < LimsException; end
|
|
25
|
+
class InvalidDate < LimsException; end
|
|
24
26
|
end
|
|
25
27
|
end
|
|
@@ -12,6 +12,8 @@ module Lab
|
|
|
12
12
|
include Utils # for logger
|
|
13
13
|
|
|
14
14
|
LIMS_LOG_PATH = Rails.root.join('log', 'lims')
|
|
15
|
+
UNMAPPED_ORDER_REASON = 'Order not mapped to LIMS: pull worker does not create orders'
|
|
16
|
+
DECEASED_PATIENT_REASON = "Order dated after patient's death"
|
|
15
17
|
|
|
16
18
|
def initialize(lims_api, start_date: nil, accession_numbers: [], patient_id: nil)
|
|
17
19
|
@lims_api = lims_api
|
|
@@ -171,47 +173,84 @@ module Lab
|
|
|
171
173
|
name1.casecmp?(name2)
|
|
172
174
|
end
|
|
173
175
|
|
|
176
|
+
##
|
|
177
|
+
# Applies a LIMS order to the local order it is mapped to.
|
|
178
|
+
#
|
|
179
|
+
# Orders are only ever created in the EMR, never by the pull worker.
|
|
180
|
+
# find_patient_by_nhid only lets an order through when a local order with
|
|
181
|
+
# the same accession number already exists, so creating one here would
|
|
182
|
+
# always produce a duplicate. Orders without a LIMS mapping (e.g. legacy
|
|
183
|
+
# orders, or new orders the push worker has not sent yet) are skipped and
|
|
184
|
+
# recorded once in lab_lims_failed_imports for review. So are orders
|
|
185
|
+
# dated after the patient's death.
|
|
186
|
+
#
|
|
187
|
+
# Returns the updated order, or nil if the order was skipped.
|
|
174
188
|
def save_order(patient, order_dto)
|
|
175
189
|
raise MissingAccessionNumber if order_dto[:tracking_number].blank?
|
|
176
190
|
|
|
177
191
|
logger.info("Importing LIMS order ##{order_dto[:tracking_number]}")
|
|
192
|
+
|
|
193
|
+
death_date = death_date_before_order(patient, order_dto)
|
|
194
|
+
if death_date
|
|
195
|
+
order_date = lims_order_date(order_dto)
|
|
196
|
+
logger.warn("Not importing order ##{order_dto[:tracking_number]}: dated #{order_date}, after patient's death on #{death_date}")
|
|
197
|
+
record_skipped_order(order_dto, DECEASED_PATIENT_REASON, { order_date:, death_date: })
|
|
198
|
+
return nil
|
|
199
|
+
end
|
|
200
|
+
|
|
178
201
|
mapping = find_order_mapping_by_lims_id(order_dto[:_id])
|
|
179
202
|
|
|
203
|
+
unless mapping
|
|
204
|
+
logger.warn("Not creating order ##{order_dto[:tracking_number]} from LIMS: no LIMS mapping for local order")
|
|
205
|
+
record_skipped_order(order_dto, UNMAPPED_ORDER_REASON)
|
|
206
|
+
return nil
|
|
207
|
+
end
|
|
208
|
+
|
|
180
209
|
ActiveRecord::Base.transaction do
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
mapping.update(pulled_at: Time.now)
|
|
184
|
-
else
|
|
185
|
-
order = create_order(patient, order_dto)
|
|
186
|
-
mapping = LimsOrderMapping.create(lims_id: order_dto[:_id],
|
|
187
|
-
order_id: order['id'],
|
|
188
|
-
pulled_at: Time.now,
|
|
189
|
-
revision: order_dto['_rev'])
|
|
190
|
-
end
|
|
210
|
+
order = update_order(patient, mapping.order_id, order_dto)
|
|
211
|
+
mapping.update(pulled_at: Time.now)
|
|
191
212
|
|
|
192
213
|
order
|
|
193
214
|
end
|
|
194
215
|
end
|
|
195
216
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
##
|
|
218
|
+
# Returns the patient's death date if the order is dated after it, else nil.
|
|
219
|
+
#
|
|
220
|
+
# Orders dated on or before the death date are still imported, so results
|
|
221
|
+
# for samples drawn before death keep arriving.
|
|
222
|
+
def death_date_before_order(patient, order_dto)
|
|
223
|
+
person = Person.unscoped.find_by(person_id: patient.patient_id)
|
|
224
|
+
return nil unless person && ActiveModel::Type::Boolean.new.cast(person.dead) && person.death_date
|
|
225
|
+
|
|
226
|
+
death_date = person.death_date.to_date
|
|
227
|
+
death_date if lims_order_date(order_dto) > death_date
|
|
228
|
+
end
|
|
201
229
|
|
|
202
|
-
|
|
203
|
-
|
|
230
|
+
def lims_order_date(order_dto)
|
|
231
|
+
raise LimsException, 'Order missing created date' if order_dto[:date_created].blank?
|
|
204
232
|
|
|
205
|
-
|
|
206
|
-
|
|
233
|
+
Utils.parse_date(order_dto[:date_created]).to_date
|
|
234
|
+
end
|
|
207
235
|
|
|
208
|
-
|
|
236
|
+
# The REST pull re-selects skipped orders on every run, so each one is
|
|
237
|
+
# recorded only once instead of adding a row per run.
|
|
238
|
+
def record_skipped_order(order_dto, reason, diff = nil)
|
|
239
|
+
LimsFailedImport.find_or_create_by!(lims_id: order_dto[:_id],
|
|
240
|
+
tracking_number: order_dto[:tracking_number],
|
|
241
|
+
reason:) do |failed_import|
|
|
242
|
+
failed_import.patient_nhid = order_dto.dig(:patient, :id)
|
|
243
|
+
failed_import.diff = diff&.to_json
|
|
244
|
+
end
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
logger.error("Failed to record skipped order ##{order_dto[:tracking_number]}: #{e.message}")
|
|
209
247
|
end
|
|
210
248
|
|
|
211
249
|
def update_order(patient, order_id, order_dto)
|
|
212
250
|
logger.debug("Updating order ##{order_dto['_id']}")
|
|
213
251
|
params = order_dto.to_order_service_params(patient_id: patient.patient_id)
|
|
214
252
|
params[:location_id] ||= Lab::OrderLocationResolver.location_id_for_order_id(order_id, facility_name: order_dto[:sending_facility])
|
|
253
|
+
keep_local_specimen(order_id, params) unless specimen_update_allowed?(order_id)
|
|
215
254
|
order = OrdersService.update_order(order_id, params.merge(force_update: 'true'))
|
|
216
255
|
|
|
217
256
|
# Extract and save status trails from NLIMS
|
|
@@ -223,6 +262,38 @@ module Lab
|
|
|
223
262
|
order
|
|
224
263
|
end
|
|
225
264
|
|
|
265
|
+
##
|
|
266
|
+
# LIMS may only set the specimen of an order whose sample has not been
|
|
267
|
+
# drawn yet (specimen 'Unknown') and that has no results. Any other order
|
|
268
|
+
# keeps its local specimen, so a sync can never change or discontinue an
|
|
269
|
+
# order that has already been drawn or resulted.
|
|
270
|
+
def specimen_update_allowed?(order_id)
|
|
271
|
+
order = Lab::LabOrder.unscoped.find(order_id)
|
|
272
|
+
|
|
273
|
+
order.concept_id == unknown_specimen_concept_id && !order_has_results?(order_id)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def keep_local_specimen(order_id, params)
|
|
277
|
+
local_specimen_id = Lab::LabOrder.unscoped.find(order_id).concept_id
|
|
278
|
+
lims_specimen_id = params.dig(:specimen, :concept_id)
|
|
279
|
+
|
|
280
|
+
if lims_specimen_id.present? && lims_specimen_id.to_i != local_specimen_id
|
|
281
|
+
logger.info("Keeping local specimen ##{local_specimen_id} on order ##{order_id}, ignoring LIMS specimen ##{lims_specimen_id}")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
params[:specimen] = { concept_id: local_specimen_id }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def unknown_specimen_concept_id
|
|
288
|
+
@unknown_specimen_concept_id ||= ConceptName.find_by(name: Lab::Metadata::UNKNOWN_SPECIMEN)&.concept_id
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def order_has_results?(order_id)
|
|
292
|
+
result_concept = ConceptName.where(name: Lab::Metadata::TEST_RESULT_CONCEPT_NAME).select(:concept_id)
|
|
293
|
+
|
|
294
|
+
Observation.unscoped.where(order_id:, concept_id: result_concept, voided: 0).exists?
|
|
295
|
+
end
|
|
296
|
+
|
|
226
297
|
def update_results(order, lims_results)
|
|
227
298
|
logger.debug("Updating results for order ##{order[:accession_number]}: #{lims_results}")
|
|
228
299
|
|
|
@@ -266,12 +337,6 @@ module Lab
|
|
|
266
337
|
LabTest.unscoped.find_by(order_id:, value_coded: test_concept.concept_id, voided: 0)
|
|
267
338
|
end
|
|
268
339
|
|
|
269
|
-
def location_id_for_order_dto(order_dto)
|
|
270
|
-
local_order = Lab::LabOrder.unscoped.find_by(accession_number: order_dto[:tracking_number])
|
|
271
|
-
|
|
272
|
-
Lab::OrderLocationResolver.location_id_for_order(local_order, facility_name: order_dto[:sending_facility])
|
|
273
|
-
end
|
|
274
|
-
|
|
275
340
|
def find_measure(_order, indicator_name, value)
|
|
276
341
|
indicator = Utils.find_concept_by_name(indicator_name)
|
|
277
342
|
unless indicator
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'cgi/util'
|
|
4
4
|
|
|
5
|
+
require_relative 'exceptions'
|
|
6
|
+
|
|
5
7
|
module Lab
|
|
6
8
|
module Lims
|
|
7
9
|
##
|
|
@@ -54,6 +56,16 @@ module Lab
|
|
|
54
56
|
User.create!(username: 'lab_daemon', person:, creator: god_user.user_id)
|
|
55
57
|
end
|
|
56
58
|
|
|
59
|
+
# Earliest date accepted from LIMS. Anything older is a corrupted
|
|
60
|
+
# legacy date (e.g. year 0026 or 18xx), not a real lab order.
|
|
61
|
+
EARLIEST_VALID_DATE = Date.new(2000, 1, 1)
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# Parses a LIMS date, accepting only real calendar dates with a
|
|
65
|
+
# four-digit year between EARLIEST_VALID_DATE and tomorrow (to allow for
|
|
66
|
+
# timezone differences). Invalid dates are never guessed at or rewritten:
|
|
67
|
+
# the fallback date is used if one is given, otherwise InvalidDate is
|
|
68
|
+
# raised.
|
|
57
69
|
def self.parse_date(str_date, fallback_date = nil)
|
|
58
70
|
str_date = str_date&.to_s
|
|
59
71
|
|
|
@@ -61,21 +73,35 @@ module Lab
|
|
|
61
73
|
|
|
62
74
|
return parse_date(fallback_date) if str_date.blank?
|
|
63
75
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
parse_date(fallback_date)
|
|
76
|
+
date = begin
|
|
77
|
+
case str_date
|
|
78
|
+
when /\d{4}-\d{2}-\d{2}/
|
|
79
|
+
str_date
|
|
80
|
+
when /\d{2}-\d{2}-\d{4}/
|
|
81
|
+
Date.strptime(str_date, '%d-%m-%Y').strftime('%Y-%m-%d')
|
|
82
|
+
when /(\d{4}\d{2}\d{2})\d+/
|
|
83
|
+
Date.strptime(str_date, '%Y%m%d').strftime('%Y-%m-%d')
|
|
84
|
+
when %r{\d{2}/\d{2}/\d{4}}
|
|
85
|
+
str_date.to_date.to_s
|
|
86
|
+
end
|
|
87
|
+
rescue Date::Error, ArgumentError
|
|
88
|
+
nil
|
|
78
89
|
end
|
|
90
|
+
|
|
91
|
+
return date if valid_date?(date)
|
|
92
|
+
|
|
93
|
+
Rails.logger.warn("Invalid date: #{str_date}")
|
|
94
|
+
raise InvalidDate, "Invalid date: #{str_date}" if fallback_date.blank?
|
|
95
|
+
|
|
96
|
+
parse_date(fallback_date)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.valid_date?(date)
|
|
100
|
+
return false if date.blank?
|
|
101
|
+
|
|
102
|
+
date.to_date.between?(EARLIEST_VALID_DATE, Date.current + 1.day)
|
|
103
|
+
rescue Date::Error, ArgumentError
|
|
104
|
+
false
|
|
79
105
|
end
|
|
80
106
|
|
|
81
107
|
def self.find_concept_by_name(name)
|
|
@@ -249,7 +249,7 @@ module Lab
|
|
|
249
249
|
orders = Lab::LabOrder.where(order_id: tests.pluck(:order_id))
|
|
250
250
|
data = {
|
|
251
251
|
count: orders.count,
|
|
252
|
-
last_order_date:
|
|
252
|
+
last_order_date: last_order_date(location_id),
|
|
253
253
|
lab_orders: []
|
|
254
254
|
}
|
|
255
255
|
if (include_data.present? && include_data == 'true')
|
|
@@ -266,6 +266,19 @@ module Lab
|
|
|
266
266
|
|
|
267
267
|
private
|
|
268
268
|
|
|
269
|
+
# Without a location this is the latest lab order overall (unchanged behaviour). With a location
|
|
270
|
+
# it is the latest lab order whose tests were drawn there, so a central EMR serving many sites
|
|
271
|
+
# reports each site's own last order instead of the latest order across all sites.
|
|
272
|
+
def last_order_date(location_id)
|
|
273
|
+
return Lab::LabOrder.last&.start_date&.to_date if location_id.blank?
|
|
274
|
+
|
|
275
|
+
order_ids = Lab::LabTest.unscoped
|
|
276
|
+
.where(voided: false, location_id:,
|
|
277
|
+
concept: ConceptName.where(name: Lab::Metadata::TEST_TYPE_CONCEPT_NAME))
|
|
278
|
+
.select(:order_id)
|
|
279
|
+
Lab::LabOrder.where(order_id: order_ids).maximum(:start_date)&.to_date
|
|
280
|
+
end
|
|
281
|
+
|
|
269
282
|
def create_rejection_notification(order_params)
|
|
270
283
|
order = find_order order_params['tracking_number']
|
|
271
284
|
data = { type: 'LIMS',
|
|
@@ -373,7 +386,7 @@ module Lab
|
|
|
373
386
|
end
|
|
374
387
|
|
|
375
388
|
def accession_number_exists?(accession_number)
|
|
376
|
-
Lab::LabOrder.where(accession_number:).exists?
|
|
389
|
+
Lab::LabOrder.unscoped.where(accession_number:, voided: 0).exists?
|
|
377
390
|
end
|
|
378
391
|
|
|
379
392
|
def nlims_accession_number_exists?(accession_number)
|
|
@@ -17,6 +17,8 @@ module Lab
|
|
|
17
17
|
def authenticate_user(username:, password:, user_agent:, request_ip:)
|
|
18
18
|
# Use unscoped for authentication - should not be location-dependent
|
|
19
19
|
user = User.unscoped.find_by_username username
|
|
20
|
+
return nil if user.nil?
|
|
21
|
+
|
|
20
22
|
encrypted_pass = Password.new(user.password)
|
|
21
23
|
if encrypted_pass == password
|
|
22
24
|
generate_token(user, user_agent, request_ip)
|
|
@@ -24,6 +26,9 @@ module Lab
|
|
|
24
26
|
# throw authentication error
|
|
25
27
|
nil
|
|
26
28
|
end
|
|
29
|
+
rescue BCrypt::Errors::InvalidHash
|
|
30
|
+
# Regular EMR users have SHA passwords; only users created through the LIMS user endpoint can log in here
|
|
31
|
+
nil
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
private
|
data/lib/lab/version.rb
CHANGED
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: 2.4.
|
|
4
|
+
version: 2.4.9
|
|
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: 2026-10-
|
|
11
|
+
date: 2026-10-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: couchrest
|
|
@@ -328,9 +328,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
328
328
|
version: '0'
|
|
329
329
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
330
330
|
requirements:
|
|
331
|
-
- - "
|
|
331
|
+
- - ">="
|
|
332
332
|
- !ruby/object:Gem::Version
|
|
333
|
-
version:
|
|
333
|
+
version: '0'
|
|
334
334
|
requirements: []
|
|
335
335
|
rubygems_version: 3.4.1
|
|
336
336
|
signing_key:
|