his_emr_api_lab 2.1.8.7 → 2.1.9.pre.alpha
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 +4 -22
- data/app/models/lab/lab_order.rb +23 -8
- data/app/models/lab/lab_result.rb +2 -2
- data/app/models/lab/lab_test.rb +21 -2
- data/app/serializers/lab/lab_order_serializer.rb +77 -7
- data/app/serializers/lab/result_serializer.rb +2 -2
- data/app/services/lab/accession_number_service.rb +2 -2
- data/app/services/lab/acknowledgement_service.rb +4 -8
- data/app/services/lab/lims/acknowledgement_worker.rb +3 -5
- data/app/services/lab/lims/api/rest_api.rb +100 -25
- data/app/services/lab/lims/order_serializer.rb +0 -1
- data/app/services/lab/lims/pull_worker.rb +172 -4
- data/app/services/lab/lims/push_worker.rb +15 -26
- data/app/services/lab/lims/worker.rb +13 -13
- data/app/services/lab/metadata.rb +1 -0
- data/app/services/lab/orders_service.rb +223 -24
- data/app/services/lab/results_service.rb +1 -5
- data/app/services/lab/tests_service.rb +48 -4
- data/db/migrate/20260226065149_create_lab_status_concepts.rb +80 -0
- data/lib/lab/version.rb +1 -1
- metadata +5 -4
|
@@ -5,15 +5,14 @@ module Lab
|
|
|
5
5
|
##
|
|
6
6
|
# Pulls orders from a Lims API object and saves them to the local database.
|
|
7
7
|
class PullWorker
|
|
8
|
-
attr_reader :lims_api
|
|
8
|
+
attr_reader :lims_api
|
|
9
9
|
|
|
10
10
|
include Utils # for logger
|
|
11
11
|
|
|
12
12
|
LIMS_LOG_PATH = Rails.root.join('log', 'lims')
|
|
13
13
|
|
|
14
|
-
def initialize(lims_api
|
|
14
|
+
def initialize(lims_api)
|
|
15
15
|
@lims_api = lims_api
|
|
16
|
-
@start_date = start_date
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
##
|
|
@@ -21,7 +20,7 @@ module Lab
|
|
|
21
20
|
def pull_orders(batch_size: 10_000, **)
|
|
22
21
|
logger.info("Retrieving LIMS orders starting from #{last_seq}")
|
|
23
22
|
|
|
24
|
-
lims_api.consume_orders(from: last_seq, limit: batch_size,
|
|
23
|
+
lims_api.consume_orders(from: last_seq, limit: batch_size, **) do |order_dto, context|
|
|
25
24
|
logger.debug("Retrieved order ##{order_dto[:tracking_number]}: #{order_dto}")
|
|
26
25
|
|
|
27
26
|
patient = find_patient_by_nhid(order_dto[:patient][:id], order_dto[:tracking_number])
|
|
@@ -192,6 +191,11 @@ module Lab
|
|
|
192
191
|
def create_order(patient, order_dto)
|
|
193
192
|
logger.debug("Creating order ##{order_dto['_id']}")
|
|
194
193
|
order = OrdersService.order_test(order_dto.to_order_service_params(patient_id: patient.patient_id))
|
|
194
|
+
|
|
195
|
+
# Extract and save status trails from NLIMS
|
|
196
|
+
save_status_trails_from_nlims(order, order_dto)
|
|
197
|
+
|
|
198
|
+
# Update results if present
|
|
195
199
|
update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
|
|
196
200
|
|
|
197
201
|
order
|
|
@@ -201,6 +205,11 @@ module Lab
|
|
|
201
205
|
logger.debug("Updating order ##{order_dto['_id']}")
|
|
202
206
|
order = OrdersService.update_order(order_id, order_dto.to_order_service_params(patient_id: patient.patient_id)
|
|
203
207
|
.merge(force_update: 'true'))
|
|
208
|
+
|
|
209
|
+
# Extract and save status trails from NLIMS
|
|
210
|
+
save_status_trails_from_nlims(order, order_dto)
|
|
211
|
+
|
|
212
|
+
# Update results if present
|
|
204
213
|
update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
|
|
205
214
|
|
|
206
215
|
order
|
|
@@ -291,6 +300,165 @@ module Lab
|
|
|
291
300
|
"#{id}:#{first_name} #{last_name}:#{phone_number}"
|
|
292
301
|
end
|
|
293
302
|
|
|
303
|
+
def save_status_trails_from_nlims(order, order_dto)
|
|
304
|
+
logger.debug("Saving status trails from NLIMS for order ##{order['order_id'] || order[:order_id]}")
|
|
305
|
+
logger.debug("Order DTO keys: #{order_dto.keys.inspect}")
|
|
306
|
+
logger.debug("Order DTO sample_statuses type: #{order_dto[:sample_statuses].class}")
|
|
307
|
+
logger.debug("Order DTO sample_statuses content: #{order_dto[:sample_statuses].inspect}")
|
|
308
|
+
|
|
309
|
+
# Extract order status trail from sample_statuses (NLIMS uses sample_statuses for order status)
|
|
310
|
+
# Note: sample_statuses is an array of single-key hashes
|
|
311
|
+
if order_dto[:sample_statuses].is_a?(Array)
|
|
312
|
+
logger.debug("Found sample_statuses: #{order_dto[:sample_statuses].size} entries")
|
|
313
|
+
save_order_status_trails(order, order_dto[:sample_statuses])
|
|
314
|
+
else
|
|
315
|
+
logger.warn("No sample_statuses found or not an Array: #{order_dto[:sample_statuses].class}")
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Extract test status trails from test_statuses
|
|
319
|
+
if order_dto['test_statuses'].is_a?(Hash)
|
|
320
|
+
logger.debug("Found test_statuses: #{order_dto['test_statuses'].keys.size} entries")
|
|
321
|
+
save_test_status_trails(order, order_dto['test_statuses'])
|
|
322
|
+
else
|
|
323
|
+
logger.debug("No test_statuses found or not a Hash: #{order_dto['test_statuses'].class}")
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def save_order_status_trails(order, sample_statuses)
|
|
328
|
+
logger.debug("Saving order status trails for order ##{order['order_id'] || order[:order_id]}")
|
|
329
|
+
logger.debug("Sample statuses: #{sample_statuses.inspect}")
|
|
330
|
+
|
|
331
|
+
# Find concept
|
|
332
|
+
order_status_concept = ConceptName.find_by(name: 'Lab Order Status')&.concept
|
|
333
|
+
unless order_status_concept
|
|
334
|
+
logger.error('Lab Order Status concept not found')
|
|
335
|
+
return
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
order_id = order['order_id'] || order[:order_id] || order['id'] || order[:id]
|
|
339
|
+
lab_order = Lab::LabOrder.find_by(order_id: order_id)
|
|
340
|
+
unless lab_order
|
|
341
|
+
logger.error("Order not found: #{order_id}")
|
|
342
|
+
return
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# sample_statuses is an array of single-key hashes like:
|
|
346
|
+
# [{ "20260225120000" => { "status" => "Drawn", ... } }, { "20260225130000" => { ... } }]
|
|
347
|
+
sample_statuses.each do |trail_entry|
|
|
348
|
+
# Each trail_entry is a hash with one timestamp key
|
|
349
|
+
trail_entry.each_pair do |timestamp_key, status_data|
|
|
350
|
+
next unless status_data.is_a?(Hash) && status_data['status']
|
|
351
|
+
|
|
352
|
+
# Parse timestamp (format: YYYYMMDDHHmmss) - already in local timezone from NLIMS conversion
|
|
353
|
+
# Use Time.zone.strptime to prevent Rails from converting timezone during save
|
|
354
|
+
begin
|
|
355
|
+
timestamp = Time.zone.strptime(timestamp_key, '%Y%m%d%H%M%S')
|
|
356
|
+
rescue StandardError => e
|
|
357
|
+
logger.warn("Failed to parse timestamp '#{timestamp_key}': #{e.message}")
|
|
358
|
+
next
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Skip if this status has already been recorded for this order (regardless of timestamp)
|
|
362
|
+
if Observation.unscoped.exists?(
|
|
363
|
+
person_id: lab_order.patient_id,
|
|
364
|
+
order_id: order_id,
|
|
365
|
+
concept_id: order_status_concept.concept_id,
|
|
366
|
+
value_text: status_data['status'],
|
|
367
|
+
voided: 0
|
|
368
|
+
)
|
|
369
|
+
logger.debug("Order status already recorded: #{status_data['status']} for order ##{order_id}")
|
|
370
|
+
next
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
updated_by = status_data['updated_by'] || {}
|
|
374
|
+
|
|
375
|
+
begin
|
|
376
|
+
Observation.create!(
|
|
377
|
+
person_id: lab_order.patient_id,
|
|
378
|
+
encounter_id: lab_order.encounter_id,
|
|
379
|
+
order_id: order_id,
|
|
380
|
+
concept_id: order_status_concept.concept_id,
|
|
381
|
+
value_text: status_data['status'], # Store status as text
|
|
382
|
+
obs_datetime: timestamp,
|
|
383
|
+
comments: updated_by.to_json,
|
|
384
|
+
creator: User.current&.user_id || 1,
|
|
385
|
+
date_created: Time.now,
|
|
386
|
+
uuid: SecureRandom.uuid
|
|
387
|
+
)
|
|
388
|
+
logger.info("Created order status trail: #{status_data['status']} at #{timestamp}")
|
|
389
|
+
rescue StandardError => e
|
|
390
|
+
logger.error("Failed to save order status trail: #{e.message}")
|
|
391
|
+
logger.error(" Order ID: #{order_id}")
|
|
392
|
+
logger.error(" Status: #{status_data['status']}")
|
|
393
|
+
logger.error(" Timestamp: #{timestamp}")
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def save_test_status_trails(order, test_statuses)
|
|
400
|
+
# Find test status concept
|
|
401
|
+
test_status_concept = ConceptName.find_by(name: 'Lab Test Status')&.concept
|
|
402
|
+
unless test_status_concept
|
|
403
|
+
logger.error('Lab Test Status concept not found')
|
|
404
|
+
return
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
test_statuses.each do |test_name, statuses|
|
|
408
|
+
next unless statuses.is_a?(Hash)
|
|
409
|
+
|
|
410
|
+
# Find the test by name
|
|
411
|
+
test_concept = Utils.find_concept_by_name(Utils.translate_test_name(test_name))
|
|
412
|
+
next unless test_concept
|
|
413
|
+
|
|
414
|
+
test = Lab::LabTest.find_by(order_id: order['order_id'], value_coded: test_concept.concept_id)
|
|
415
|
+
next unless test
|
|
416
|
+
|
|
417
|
+
# Process each status in the trail
|
|
418
|
+
statuses.each do |timestamp_key, status_data|
|
|
419
|
+
next unless status_data.is_a?(Hash) && status_data['status']
|
|
420
|
+
|
|
421
|
+
# Parse timestamp (format: YYYYMMDDHHmmss) - already in local timezone from NLIMS conversion
|
|
422
|
+
# Use Time.zone.strptime to prevent Rails from converting timezone during save
|
|
423
|
+
begin
|
|
424
|
+
timestamp = Time.zone.strptime(timestamp_key, '%Y%m%d%H%M%S')
|
|
425
|
+
rescue StandardError => e
|
|
426
|
+
logger.warn("Failed to parse timestamp '#{timestamp_key}': #{e.message}")
|
|
427
|
+
next
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Skip if this status has already been recorded for this test (regardless of timestamp)
|
|
431
|
+
next if Observation.unscoped.exists?(
|
|
432
|
+
person_id: test.person_id,
|
|
433
|
+
obs_group_id: test.obs_id,
|
|
434
|
+
concept_id: test_status_concept.concept_id,
|
|
435
|
+
value_text: status_data['status'],
|
|
436
|
+
voided: 0
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
updated_by = status_data['updated_by'] || {}
|
|
440
|
+
|
|
441
|
+
begin
|
|
442
|
+
Observation.create!(
|
|
443
|
+
person_id: test.person_id,
|
|
444
|
+
encounter_id: test.encounter_id,
|
|
445
|
+
obs_group_id: test.obs_id,
|
|
446
|
+
concept_id: test_status_concept.concept_id,
|
|
447
|
+
value_text: status_data['status'], # Store status as text
|
|
448
|
+
obs_datetime: timestamp,
|
|
449
|
+
comments: updated_by.to_json,
|
|
450
|
+
creator: User.current&.user_id || 1,
|
|
451
|
+
date_created: Time.now,
|
|
452
|
+
uuid: SecureRandom.uuid
|
|
453
|
+
)
|
|
454
|
+
logger.info("Created test status trail: #{status_data['status']} at #{timestamp} for #{test_name}")
|
|
455
|
+
rescue StandardError => e
|
|
456
|
+
logger.error("Failed to save test status trail for #{test_name}: #{e.message}")
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
294
462
|
def save_failed_import(order_dto, reason, diff = nil)
|
|
295
463
|
logger.info("Failed to import LIMS order ##{order_dto[:tracking_number]} due to '#{reason}'")
|
|
296
464
|
LimsFailedImport.create!(lims_id: order_dto[:_id],
|
|
@@ -5,16 +5,15 @@ module Lab
|
|
|
5
5
|
##
|
|
6
6
|
# Pushes all local orders to a LIMS Api object.
|
|
7
7
|
class PushWorker
|
|
8
|
-
attr_reader :lims_api
|
|
8
|
+
attr_reader :lims_api
|
|
9
9
|
|
|
10
10
|
include Utils # for logger
|
|
11
11
|
|
|
12
12
|
SECONDS_TO_WAIT_FOR_ORDERS = 30
|
|
13
13
|
START_DATE = Time.parse('2024-09-03').freeze
|
|
14
14
|
|
|
15
|
-
def initialize(lims_api
|
|
15
|
+
def initialize(lims_api)
|
|
16
16
|
@lims_api = lims_api
|
|
17
|
-
@start_date = start_date
|
|
18
17
|
end
|
|
19
18
|
|
|
20
19
|
def push_orders(batch_size: 1000, wait: false)
|
|
@@ -82,8 +81,8 @@ module Lab
|
|
|
82
81
|
|
|
83
82
|
def void_order_in_lims(order_id)
|
|
84
83
|
order = Lab::LabOrder.joins(order_type: { name: 'Lab' })
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
.unscoped
|
|
85
|
+
.find(order_id)
|
|
87
86
|
order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
|
|
88
87
|
Rails.logger.info("Deleting order ##{order_dto[:accession_number]} from LIMS")
|
|
89
88
|
lims_api.delete_order('', order_dto)
|
|
@@ -101,17 +100,10 @@ module Lab
|
|
|
101
100
|
|
|
102
101
|
def new_orders
|
|
103
102
|
Rails.logger.debug('Looking for new orders that need to be created in LIMS...')
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
query = if start_date
|
|
109
|
-
query.where('orders.date_created >= ?', start_date)
|
|
110
|
-
else
|
|
111
|
-
query.where('orders.date_created >= ? AND orders.date_created <= ?', START_DATE, Date.today + 1.day)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
query.order(date_created: :desc)
|
|
103
|
+
Lab::LabOrder.where.not(order_id: Lab::LimsOrderMapping.all.select(:order_id))
|
|
104
|
+
.where("accession_number IS NOT NULL AND accession_number !=''")
|
|
105
|
+
.where(date_created: START_DATE..(Date.today + 1.day))
|
|
106
|
+
.order(date_created: :desc)
|
|
115
107
|
end
|
|
116
108
|
|
|
117
109
|
def updated_orders
|
|
@@ -119,26 +111,23 @@ module Lab
|
|
|
119
111
|
last_updated = Lab::LimsOrderMapping.select('MAX(updated_at) AS last_updated')
|
|
120
112
|
.first
|
|
121
113
|
.last_updated
|
|
122
|
-
last_updated = start_date if start_date && last_updated < start_date
|
|
123
114
|
|
|
124
115
|
Lab::LabOrder.left_joins(:results)
|
|
125
116
|
.joins(:mapping)
|
|
126
|
-
.where('
|
|
127
|
-
OR
|
|
117
|
+
.where('orders.discontinued_date > :last_updated
|
|
118
|
+
OR obs.date_created > orders.date_created AND lab_lims_order_mappings.result_push_status = 0',
|
|
128
119
|
last_updated:)
|
|
129
120
|
.group('orders.order_id')
|
|
130
121
|
.order(discontinued_date: :desc, date_created: :desc)
|
|
131
122
|
end
|
|
132
123
|
|
|
133
124
|
def voided_orders
|
|
134
|
-
# add date filter to avoid pushing voided orders that were created a long time ago
|
|
135
125
|
Rails.logger.debug('Looking for voided orders that are being tracked by LIMS...')
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
orders.order(date_voided: :desc)
|
|
126
|
+
Lab::LabOrder.unscoped
|
|
127
|
+
.where(order_type: OrderType.where(name: Lab::Metadata::ORDER_TYPE_NAME),
|
|
128
|
+
order_id: Lab::LimsOrderMapping.all.select(:order_id),
|
|
129
|
+
voided: 1)
|
|
130
|
+
.order(date_voided: :desc)
|
|
142
131
|
end
|
|
143
132
|
|
|
144
133
|
##
|
|
@@ -9,43 +9,43 @@ module Lab
|
|
|
9
9
|
##
|
|
10
10
|
# Pull/Push orders from/to the LIMS queue (Oops meant CouchDB).
|
|
11
11
|
module Worker
|
|
12
|
-
def self.start
|
|
12
|
+
def self.start
|
|
13
13
|
User.current = Utils.lab_user
|
|
14
14
|
|
|
15
|
-
fork
|
|
16
|
-
fork
|
|
17
|
-
fork
|
|
18
|
-
fork
|
|
15
|
+
fork(&method(:start_push_worker))
|
|
16
|
+
fork(&method(:start_pull_worker))
|
|
17
|
+
fork(&method(:start_acknowledgement_worker))
|
|
18
|
+
fork(&method(:start_realtime_pull_worker)) if realtime_updates_enabled?
|
|
19
19
|
|
|
20
20
|
Process.waitall
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def self.start_push_worker
|
|
23
|
+
def self.start_push_worker
|
|
24
24
|
start_worker('push_worker') do
|
|
25
|
-
worker = PushWorker.new(lims_api
|
|
25
|
+
worker = PushWorker.new(lims_api)
|
|
26
26
|
|
|
27
27
|
worker.push_orders # (wait: true)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def self.start_acknowledgement_worker
|
|
31
|
+
def self.start_acknowledgement_worker
|
|
32
32
|
start_worker('acknowledgement_worker') do
|
|
33
|
-
worker = AcknowledgementWorker.new(lims_api
|
|
33
|
+
worker = AcknowledgementWorker.new(lims_api)
|
|
34
34
|
worker.push_acknowledgement
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def self.start_pull_worker
|
|
38
|
+
def self.start_pull_worker
|
|
39
39
|
start_worker('pull_worker') do
|
|
40
|
-
worker = PullWorker.new(lims_api
|
|
40
|
+
worker = PullWorker.new(lims_api)
|
|
41
41
|
|
|
42
42
|
worker.pull_orders
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def self.start_realtime_pull_worker
|
|
46
|
+
def self.start_realtime_pull_worker
|
|
47
47
|
start_worker('realtime_pull_worker') do
|
|
48
|
-
worker = PullWorker.new(Lims::Api::WsApi.new(Lab::Lims::Config.updates_socket)
|
|
48
|
+
worker = PullWorker.new(Lims::Api::WsApi.new(Lab::Lims::Config.updates_socket))
|
|
49
49
|
|
|
50
50
|
worker.pull_orders
|
|
51
51
|
end
|
|
@@ -12,6 +12,7 @@ module Lab
|
|
|
12
12
|
TEST_RESULT_INDICATOR_CONCEPT_NAME = 'Lab test result indicator'
|
|
13
13
|
TEST_TYPE_CONCEPT_NAME = 'Test type'
|
|
14
14
|
LAB_ORDER_STATUS_CONCEPT_NAME = 'lab order status'
|
|
15
|
+
LAB_TEST_STATUS_CONCEPT_NAME = 'lab test status'
|
|
15
16
|
UNKNOWN_SPECIMEN = 'Unknown'
|
|
16
17
|
VISIT_TYPE_UUID = '8a4a4488-c2cc-11de-8d13-0010c6dffd0f'
|
|
17
18
|
TEST_METHOD_CONCEPT_NAME = 'Recommended test method'
|