his_emr_api_lab 2.1.9.pre.beta → 2.2.1
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 +17 -4
- data/app/controllers/lab/orders_controller.rb +23 -3
- data/app/jobs/lab/process_lab_result_job.rb +1 -4
- data/app/jobs/lab/update_patient_orders_job.rb +1 -50
- data/app/jobs/lab/void_order_job.rb +2 -6
- 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/services/lab/acknowledgement_service.rb +8 -4
- data/app/services/lab/lims/acknowledgement_worker.rb +5 -3
- data/app/services/lab/lims/api/rest_api.rb +139 -47
- data/app/services/lab/lims/order_serializer.rb +29 -39
- data/app/services/lab/lims/pull_worker.rb +173 -3
- data/app/services/lab/lims/push_worker.rb +15 -8
- data/app/services/lab/lims/utils.rb +1 -2
- data/app/services/lab/lims/worker.rb +13 -13
- data/app/services/lab/metadata.rb +1 -0
- data/app/services/lab/notification_service.rb +1 -2
- data/app/services/lab/orders_service.rb +225 -25
- data/app/services/lab/results_service.rb +1 -1
- data/app/services/lab/tests_service.rb +48 -4
- data/app/services/lab/user_service.rb +2 -3
- data/db/migrate/20260226065149_create_lab_status_concepts.rb +80 -0
- data/lib/lab/version.rb +1 -1
- metadata +5 -4
|
@@ -74,18 +74,85 @@ module Lab
|
|
|
74
74
|
{ tracking_number: order_dto[:tracking_number] }
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
def consume_orders(*_args, patient_id: nil, **_kwargs)
|
|
78
|
-
orders_pending_updates(patient_id).each do |order|
|
|
77
|
+
def consume_orders(*_args, patient_id: nil, start_date: nil, **_kwargs)
|
|
78
|
+
orders_pending_updates(patient_id, start_date: start_date).each do |order|
|
|
79
79
|
order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
|
|
81
|
+
# Always fetch the full order from NLIMS to get status trails
|
|
82
|
+
begin
|
|
83
|
+
lims_order = find_lims_order(order.accession_number)
|
|
84
|
+
patch_order_dto_with_lims_order!(order_dto, lims_order)
|
|
85
|
+
|
|
86
|
+
Rails.logger.debug("NLIMS order structure for #{order.accession_number}:")
|
|
87
|
+
Rails.logger.debug(" Has 'order' key: #{lims_order.key?('order')}")
|
|
88
|
+
Rails.logger.debug(" Has 'data' key: #{lims_order.key?('data')}")
|
|
89
|
+
Rails.logger.debug(" Top level keys: #{lims_order.keys.inspect}")
|
|
90
|
+
|
|
91
|
+
# Also extract status trails from the NLIMS order
|
|
92
|
+
# Note: NLIMS might return order data under 'order' or 'data.order'
|
|
93
|
+
order_data = lims_order['order'] || lims_order.dig('data', 'order') || lims_order
|
|
94
|
+
|
|
95
|
+
if order_data && order_data['status_trail']
|
|
96
|
+
Rails.logger.info("Found #{order_data['status_trail'].size} order status trail entries from NLIMS")
|
|
97
|
+
order_dto[:sample_statuses] ||= []
|
|
98
|
+
# Convert NLIMS status trail to the format expected by PullWorker
|
|
99
|
+
# Note: sample_statuses must be an array of single-key hashes
|
|
100
|
+
order_data['status_trail'].each do |trail|
|
|
101
|
+
# Convert ISO 8601 timestamp to YYYYMMDDHHmmss format
|
|
102
|
+
timestamp_key = convert_timestamp_to_key(trail['timestamp'])
|
|
103
|
+
order_dto[:sample_statuses] << {
|
|
104
|
+
timestamp_key => {
|
|
105
|
+
'status_id' => trail['status_id'],
|
|
106
|
+
'status' => trail['status'],
|
|
107
|
+
'updated_by' => trail['updated_by']
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
Rails.logger.debug(" Added order status: #{trail['status']} at #{timestamp_key}")
|
|
111
|
+
end
|
|
112
|
+
Rails.logger.debug("Final sample_statuses: #{order_dto[:sample_statuses].inspect}")
|
|
113
|
+
else
|
|
114
|
+
Rails.logger.warn("No order status_trail found in NLIMS response for #{order.accession_number}")
|
|
115
|
+
Rails.logger.debug("Order data keys: #{order_data&.keys&.inspect}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Extract test status trails from NLIMS tests
|
|
119
|
+
tests_data = lims_order['tests'] || lims_order.dig('data', 'tests') || []
|
|
120
|
+
if tests_data.is_a?(Array)
|
|
121
|
+
Rails.logger.debug("Processing #{tests_data.size} tests from NLIMS")
|
|
122
|
+
order_dto['test_statuses'] ||= {}
|
|
123
|
+
tests_data.each do |test|
|
|
124
|
+
next unless test['status_trail'].is_a?(Array)
|
|
125
|
+
|
|
126
|
+
test_name = test.dig('test_type', 'name')
|
|
127
|
+
next unless test_name
|
|
128
|
+
|
|
129
|
+
Rails.logger.debug(" Found #{test['status_trail'].size} status trail entries for test #{test_name}")
|
|
130
|
+
order_dto['test_statuses'][test_name] ||= {}
|
|
131
|
+
test['status_trail'].each do |trail|
|
|
132
|
+
# Convert ISO 8601 timestamp to YYYYMMDDHHmmss format
|
|
133
|
+
timestamp_key = convert_timestamp_to_key(trail['timestamp'])
|
|
134
|
+
order_dto['test_statuses'][test_name][timestamp_key] = {
|
|
135
|
+
'status_id' => trail['status_id'],
|
|
136
|
+
'status' => trail['status'],
|
|
137
|
+
'updated_by' => trail['updated_by']
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
rescue RestClient::NotFound
|
|
143
|
+
Rails.logger.warn("Order ##{order.accession_number} not found in NLIMS, using local data only")
|
|
82
144
|
end
|
|
145
|
+
|
|
146
|
+
# Try to fetch results if available
|
|
83
147
|
if order_dto['test_results'].empty?
|
|
84
148
|
begin
|
|
85
149
|
patch_order_dto_with_lims_results!(order_dto, find_lims_results(order.accession_number))
|
|
86
|
-
rescue InvalidParameters => e
|
|
87
|
-
Rails.logger.
|
|
88
|
-
|
|
150
|
+
rescue InvalidParameters => e
|
|
151
|
+
Rails.logger.info("No results available for ##{order.accession_number}: #{e.message}")
|
|
152
|
+
# Don't skip - continue processing to save status trails
|
|
153
|
+
rescue RestClient::NotFound
|
|
154
|
+
Rails.logger.info("No results found for ##{order.accession_number}")
|
|
155
|
+
# Don't skip - continue processing to save status trails
|
|
89
156
|
end
|
|
90
157
|
end
|
|
91
158
|
|
|
@@ -227,7 +294,7 @@ module Lab
|
|
|
227
294
|
{
|
|
228
295
|
order: {
|
|
229
296
|
tracking_number: order_dto.fetch(:tracking_number),
|
|
230
|
-
district:
|
|
297
|
+
district: current_district,
|
|
231
298
|
health_facility_name: order_dto.fetch(:sending_facility),
|
|
232
299
|
sending_facility: order_dto.fetch(:sending_facility),
|
|
233
300
|
arv_number: order_dto.fetch(:patient).fetch(:arv_number),
|
|
@@ -281,17 +348,24 @@ module Lab
|
|
|
281
348
|
status: 'specimen_collected',
|
|
282
349
|
time_updated: date_updated,
|
|
283
350
|
sample_type: order_dto.fetch(:sample_type_map),
|
|
284
|
-
updated_by: status.fetch(:updated_by)
|
|
285
|
-
status_trail: [
|
|
286
|
-
updated_by: {
|
|
287
|
-
first_name: status.fetch(:updated_by).fetch(:first_name),
|
|
288
|
-
last_name: status.fetch(:updated_by).fetch(:last_name),
|
|
289
|
-
id_number: status.fetch(:updated_by).fetch(:id)
|
|
290
|
-
}
|
|
291
|
-
]
|
|
351
|
+
updated_by: status.fetch(:updated_by)
|
|
292
352
|
}
|
|
293
353
|
end
|
|
294
354
|
|
|
355
|
+
def current_district
|
|
356
|
+
health_centre = Location.current_health_center
|
|
357
|
+
raise 'Current health centre not set' unless health_centre
|
|
358
|
+
|
|
359
|
+
district = health_centre.district || Lab::Lims::Config.application['district']
|
|
360
|
+
|
|
361
|
+
unless district
|
|
362
|
+
health_centre_name = "##{health_centre.id} - #{health_centre.name}"
|
|
363
|
+
raise "Current health centre district not set: #{health_centre_name}"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
district
|
|
367
|
+
end
|
|
368
|
+
|
|
295
369
|
##
|
|
296
370
|
# Extracts sample drawn status from an OrderDto
|
|
297
371
|
def sample_drawn_status(order_dto)
|
|
@@ -437,25 +511,23 @@ module Lab
|
|
|
437
511
|
order_dto['test_results'].each do |test_name, results|
|
|
438
512
|
Rails.logger.info("Pushing result for order ##{order_dto['tracking_number']}")
|
|
439
513
|
in_authenticated_session do |headers|
|
|
440
|
-
params = make_update_test_params(order_dto, test_name, results)
|
|
514
|
+
params = make_update_test_params(order_dto['tracking_number'], test_name, results)
|
|
441
515
|
|
|
442
|
-
RestClient.
|
|
516
|
+
RestClient.post(expand_uri("tests/#{order_dto['tracking_number']}", api_version: 'v2'), params, headers)
|
|
443
517
|
end
|
|
444
518
|
end
|
|
445
519
|
end
|
|
446
520
|
|
|
447
|
-
def make_update_test_params(
|
|
448
|
-
# Find the concept from the test name (which is a string)
|
|
449
|
-
concept = ::ConceptName.find_by(name: test)&.concept
|
|
521
|
+
def make_update_test_params(_tracking_number, test, results, test_status = 'Drawn')
|
|
450
522
|
{
|
|
451
523
|
test_status:,
|
|
452
524
|
time_updated: results['result_date'],
|
|
453
525
|
test_type: {
|
|
454
|
-
name:
|
|
455
|
-
nlims_code:
|
|
526
|
+
name: ::Concept.find(test.concept_id).test_catalogue_name,
|
|
527
|
+
nlims_code: ::Concept.find(test.concept_id).nlims_code
|
|
456
528
|
},
|
|
457
|
-
test_results: results['results'].map do |
|
|
458
|
-
measure_value =
|
|
529
|
+
test_results: results['results'].map do |measure, _value|
|
|
530
|
+
measure_name, measure_value = measure
|
|
459
531
|
{
|
|
460
532
|
measure: {
|
|
461
533
|
name: measure_name,
|
|
@@ -467,18 +539,6 @@ module Lab
|
|
|
467
539
|
result_date: results['result_date']
|
|
468
540
|
}
|
|
469
541
|
}
|
|
470
|
-
end,
|
|
471
|
-
status_trail: order_dto['sample_statuses'].map do |trail_entry|
|
|
472
|
-
date, status = trail_entry.each_pair.first
|
|
473
|
-
{
|
|
474
|
-
status: status['status'],
|
|
475
|
-
timestamp: date,
|
|
476
|
-
updated_by: {
|
|
477
|
-
first_name: status.fetch('updated_by').fetch('first_name'),
|
|
478
|
-
last_name: status.fetch('updated_by').fetch('last_name'),
|
|
479
|
-
id_number: status.fetch('updated_by').fetch('id')
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
542
|
end
|
|
483
543
|
}
|
|
484
544
|
end
|
|
@@ -517,46 +577,64 @@ module Lab
|
|
|
517
577
|
}
|
|
518
578
|
end
|
|
519
579
|
|
|
520
|
-
def orders_pending_updates(patient_id = nil)
|
|
580
|
+
def orders_pending_updates(patient_id = nil, start_date: nil)
|
|
521
581
|
Rails.logger.info('Looking for orders that need to be updated...')
|
|
522
582
|
orders = {}
|
|
523
583
|
|
|
524
|
-
orders_without_specimen(patient_id).each { |order| orders[order.order_id] = order }
|
|
525
|
-
orders_without_results(patient_id).each { |order| orders[order.order_id] = order }
|
|
526
|
-
orders_without_reason(patient_id).each { |order| orders[order.order_id] = order }
|
|
584
|
+
orders_without_specimen(patient_id, start_date: start_date).each { |order| orders[order.order_id] = order }
|
|
585
|
+
orders_without_results(patient_id, start_date: start_date).each { |order| orders[order.order_id] = order }
|
|
586
|
+
orders_without_reason(patient_id, start_date: start_date).each { |order| orders[order.order_id] = order }
|
|
527
587
|
|
|
528
588
|
orders.values
|
|
529
589
|
end
|
|
530
590
|
|
|
531
|
-
def orders_without_specimen(patient_id = nil)
|
|
591
|
+
def orders_without_specimen(patient_id = nil, start_date: nil)
|
|
532
592
|
Rails.logger.debug('Looking for orders without a specimen')
|
|
533
593
|
unknown_specimen = ConceptName.where(name: Lab::Metadata::UNKNOWN_SPECIMEN)
|
|
534
594
|
.select(:concept_id)
|
|
535
595
|
orders = Lab::LabOrder.where(concept_id: unknown_specimen)
|
|
536
596
|
.where.not(accession_number: Lab::LimsOrderMapping.select(:lims_id))
|
|
537
597
|
orders = orders.where(patient_id:) if patient_id
|
|
598
|
+
orders = orders.where('orders.date_created >= ?', start_date) if start_date
|
|
538
599
|
|
|
539
600
|
orders
|
|
540
601
|
end
|
|
541
602
|
|
|
542
|
-
def orders_without_results(patient_id = nil)
|
|
603
|
+
def orders_without_results(patient_id = nil, start_date: nil)
|
|
543
604
|
Rails.logger.debug('Looking for orders without a result')
|
|
544
605
|
# Lab::OrdersSearchService.find_orders_without_results(patient_id: patient_id)
|
|
545
606
|
# .where.not(accession_number: Lab::LimsOrderMapping.select(:lims_id).where("pulled_at IS NULL"))
|
|
546
|
-
Lab::OrdersSearchService.find_orders_without_results(patient_id:)
|
|
547
|
-
|
|
607
|
+
orders = Lab::OrdersSearchService.find_orders_without_results(patient_id:)
|
|
608
|
+
.where(order_id: Lab::LimsOrderMapping.select(:order_id))
|
|
609
|
+
orders = orders.where('orders.date_created >= ?', start_date) if start_date
|
|
610
|
+
orders
|
|
548
611
|
end
|
|
549
612
|
|
|
550
|
-
def orders_without_reason(patient_id = nil)
|
|
613
|
+
def orders_without_reason(patient_id = nil, start_date: nil)
|
|
551
614
|
Rails.logger.debug('Looking for orders without a reason for test')
|
|
552
615
|
orders = Lab::LabOrder.joins(:reason_for_test)
|
|
553
616
|
.merge(Observation.where(value_coded: nil, value_text: nil))
|
|
554
617
|
.limit(1000)
|
|
555
618
|
.where.not(accession_number: Lab::LimsOrderMapping.select(:lims_id))
|
|
556
619
|
orders = orders.where(patient_id:) if patient_id
|
|
620
|
+
orders = orders.where('orders.date_created >= ?', start_date) if start_date
|
|
557
621
|
|
|
558
622
|
orders
|
|
559
623
|
end
|
|
624
|
+
|
|
625
|
+
# Converts ISO 8601 timestamp to YYYYMMDDHHmmss format
|
|
626
|
+
def convert_timestamp_to_key(timestamp)
|
|
627
|
+
return timestamp if timestamp.nil? || timestamp.empty?
|
|
628
|
+
|
|
629
|
+
begin
|
|
630
|
+
# Parse ISO 8601 timestamp and format as YYYYMMDDHHmmss
|
|
631
|
+
Time.parse(timestamp).strftime('%Y%m%d%H%M%S')
|
|
632
|
+
rescue StandardError => e
|
|
633
|
+
Rails.logger.warn("Failed to parse timestamp '#{timestamp}': #{e.message}")
|
|
634
|
+
# Fallback: remove all non-digits
|
|
635
|
+
timestamp.to_s.gsub(/\D/, '')
|
|
636
|
+
end
|
|
637
|
+
end
|
|
560
638
|
end
|
|
561
639
|
end
|
|
562
640
|
end
|
|
@@ -685,7 +763,7 @@ module Lab
|
|
|
685
763
|
def make_create_params(order_dto)
|
|
686
764
|
{
|
|
687
765
|
tracking_number: order_dto.fetch(:tracking_number),
|
|
688
|
-
district:
|
|
766
|
+
district: current_district,
|
|
689
767
|
health_facility_name: order_dto.fetch(:sending_facility),
|
|
690
768
|
first_name: order_dto.fetch(:patient).fetch(:first_name),
|
|
691
769
|
last_name: order_dto.fetch(:patient).fetch(:last_name),
|
|
@@ -723,6 +801,20 @@ module Lab
|
|
|
723
801
|
}
|
|
724
802
|
end
|
|
725
803
|
|
|
804
|
+
def current_district
|
|
805
|
+
health_centre = Location.current_health_center
|
|
806
|
+
raise 'Current health centre not set' unless health_centre
|
|
807
|
+
|
|
808
|
+
district = health_centre.district || Lab::Lims::Config.application['district']
|
|
809
|
+
|
|
810
|
+
unless district
|
|
811
|
+
health_centre_name = "##{health_centre.id} - #{health_centre.name}"
|
|
812
|
+
raise "Current health centre district not set: #{health_centre_name}"
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
district
|
|
816
|
+
end
|
|
817
|
+
|
|
726
818
|
##
|
|
727
819
|
# Extracts sample drawn status from an OrderDto
|
|
728
820
|
def sample_drawn_status(order_dto)
|
|
@@ -14,17 +14,15 @@ module Lab
|
|
|
14
14
|
|
|
15
15
|
def serialize_order(order)
|
|
16
16
|
serialized_order = Lims::Utils.structify(Lab::LabOrderSerializer.serialize_order(order))
|
|
17
|
-
location = get_location(serialized_order.location_id, order)
|
|
18
|
-
|
|
19
17
|
Lims::OrderDto.new(
|
|
20
18
|
_id: Lab::LimsOrderMapping.find_by(order: order)&.lims_id || serialized_order.accession_number,
|
|
21
19
|
tracking_number: serialized_order.accession_number,
|
|
22
|
-
sending_facility:
|
|
20
|
+
sending_facility: current_facility_name,
|
|
23
21
|
receiving_facility: serialized_order.target_lab,
|
|
24
22
|
tests: serialized_order.tests.map { |test| format_test_name(test.name) },
|
|
25
23
|
tests_map: serialized_order.tests,
|
|
26
24
|
patient: format_patient(serialized_order.patient_id),
|
|
27
|
-
order_location:
|
|
25
|
+
order_location: format_order_location(serialized_order.encounter_id),
|
|
28
26
|
sample_type: format_sample_type(serialized_order.specimen.name),
|
|
29
27
|
sample_type_map: {
|
|
30
28
|
name: format_sample_type(serialized_order.specimen.name),
|
|
@@ -34,7 +32,7 @@ module Lab
|
|
|
34
32
|
sample_statuses: format_sample_status_trail(order),
|
|
35
33
|
test_statuses: format_test_status_trail(order),
|
|
36
34
|
who_order_test: format_orderer(order),
|
|
37
|
-
districy:
|
|
35
|
+
districy: current_district, # yes districy [sic]...
|
|
38
36
|
priority: format_sample_priority(serialized_order.reason_for_test.name),
|
|
39
37
|
date_created: serialized_order.order_date,
|
|
40
38
|
test_results: format_test_results(serialized_order),
|
|
@@ -45,26 +43,13 @@ module Lab
|
|
|
45
43
|
|
|
46
44
|
private
|
|
47
45
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
location = Location.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
location ||= Location.current_health_center
|
|
54
|
-
|
|
55
|
-
# Last fallback: try to get from order's observation encounter
|
|
56
|
-
# Use unscoped to find observations/encounters across all locations
|
|
57
|
-
if location.nil? && order.present?
|
|
58
|
-
obs = Observation.unscoped.find_by(order_id: order.order_id)
|
|
59
|
-
if obs.respond_to?(:encounter) && obs.encounter.respond_to?(:location)
|
|
60
|
-
encounter = Encounter.unscoped.find_by(encounter_id: obs.encounter_id)
|
|
61
|
-
location = encounter&.location
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
raise 'Current health center not set' unless location
|
|
46
|
+
def format_order_location(encounter_id)
|
|
47
|
+
location_id = Encounter.select(:location_id).where(encounter_id:)
|
|
48
|
+
location = Location.select(:name)
|
|
49
|
+
.where(location_id:)
|
|
50
|
+
.first
|
|
66
51
|
|
|
67
|
-
location
|
|
52
|
+
location&.name
|
|
68
53
|
end
|
|
69
54
|
|
|
70
55
|
# Format patient into a structure that LIMS expects
|
|
@@ -138,13 +123,11 @@ module Lab
|
|
|
138
123
|
def format_sample_status_trail(order)
|
|
139
124
|
return [] if order.concept_id == ConceptName.find_by_name!('Unknown').concept_id
|
|
140
125
|
|
|
141
|
-
user = User.
|
|
142
|
-
user = User.
|
|
126
|
+
user = User.find(order.creator)
|
|
127
|
+
user = User.find(order.discontinued_by) if Order.columns_hash.key?('discontinued_by') && user.blank?
|
|
143
128
|
|
|
144
129
|
drawn_by = PersonName.find_by_person_id(user.user_id)
|
|
145
|
-
drawn_date = order.discontinued_date || order.start_date if
|
|
146
|
-
order.respond_to?(column)
|
|
147
|
-
end
|
|
130
|
+
drawn_date = order.discontinued_date || order.start_date if ['discontinued_date', 'start_date'].all? { |column| order.respond_to?(column) }
|
|
148
131
|
drawn_date ||= order.date_created
|
|
149
132
|
|
|
150
133
|
[
|
|
@@ -196,15 +179,13 @@ module Lab
|
|
|
196
179
|
order.tests&.each_with_object({}) do |test, results|
|
|
197
180
|
next if test.result.nil? || test.result.empty?
|
|
198
181
|
|
|
199
|
-
|
|
200
|
-
result_obs = Observation.unscoped.find_by(obs_id: test.result.first.id)
|
|
182
|
+
result_obs = Observation.find_by(obs_id: test.result.first.id)
|
|
201
183
|
unless result_obs
|
|
202
184
|
Rails.logger.warn("Observation with obs_id=#{test.result.first.id} not found for test #{test.name} in order #{order.accession_number}")
|
|
203
185
|
next
|
|
204
186
|
end
|
|
205
187
|
|
|
206
|
-
|
|
207
|
-
test_creator = User.unscoped.find(result_obs.creator)
|
|
188
|
+
test_creator = User.find(result_obs.creator)
|
|
208
189
|
test_creator_name = PersonName.find_by_person_id(test_creator.person_id)
|
|
209
190
|
|
|
210
191
|
results[format_test_name(test.name)] = {
|
|
@@ -227,7 +208,7 @@ module Lab
|
|
|
227
208
|
end
|
|
228
209
|
|
|
229
210
|
def format_test_name(test_name)
|
|
230
|
-
test_name
|
|
211
|
+
return test_name
|
|
231
212
|
end
|
|
232
213
|
|
|
233
214
|
def format_sample_priority(priority)
|
|
@@ -236,11 +217,16 @@ module Lab
|
|
|
236
217
|
priority&.titleize
|
|
237
218
|
end
|
|
238
219
|
|
|
239
|
-
def
|
|
240
|
-
|
|
220
|
+
def current_health_center
|
|
221
|
+
health_center = Location.current_health_center
|
|
222
|
+
raise 'Current health center not set' unless health_center
|
|
223
|
+
|
|
224
|
+
health_center
|
|
225
|
+
end
|
|
241
226
|
|
|
242
|
-
|
|
243
|
-
|
|
227
|
+
def current_district
|
|
228
|
+
district = current_health_center.city_village \
|
|
229
|
+
|| current_health_center.parent&.name \
|
|
244
230
|
|| GlobalProperty.find_by_property('current_health_center_district')&.property_value
|
|
245
231
|
|
|
246
232
|
return district if district
|
|
@@ -252,8 +238,12 @@ module Lab
|
|
|
252
238
|
Config.application['district']
|
|
253
239
|
end
|
|
254
240
|
|
|
241
|
+
def current_facility_name
|
|
242
|
+
current_health_center.name
|
|
243
|
+
end
|
|
244
|
+
|
|
255
245
|
def find_user(user_id)
|
|
256
|
-
user = User.
|
|
246
|
+
user = User.find(user_id)
|
|
257
247
|
person_name = PersonName.find_by(person_id: user.person_id)
|
|
258
248
|
phone_number = PersonAttribute.find_by(type: PersonAttributeType.where(name: 'Cell phone number'),
|
|
259
249
|
person_id: user.person_id)
|
|
@@ -5,14 +5,15 @@ 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, :start_date
|
|
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, start_date: nil)
|
|
15
15
|
@lims_api = lims_api
|
|
16
|
+
@start_date = start_date
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
##
|
|
@@ -20,7 +21,7 @@ module Lab
|
|
|
20
21
|
def pull_orders(batch_size: 10_000, **)
|
|
21
22
|
logger.info("Retrieving LIMS orders starting from #{last_seq}")
|
|
22
23
|
|
|
23
|
-
lims_api.consume_orders(from: last_seq, limit: batch_size, **) do |order_dto, context|
|
|
24
|
+
lims_api.consume_orders(from: last_seq, limit: batch_size, start_date: start_date, **) do |order_dto, context|
|
|
24
25
|
logger.debug("Retrieved order ##{order_dto[:tracking_number]}: #{order_dto}")
|
|
25
26
|
|
|
26
27
|
patient = find_patient_by_nhid(order_dto[:patient][:id], order_dto[:tracking_number])
|
|
@@ -191,6 +192,11 @@ module Lab
|
|
|
191
192
|
def create_order(patient, order_dto)
|
|
192
193
|
logger.debug("Creating order ##{order_dto['_id']}")
|
|
193
194
|
order = OrdersService.order_test(order_dto.to_order_service_params(patient_id: patient.patient_id))
|
|
195
|
+
|
|
196
|
+
# Extract and save status trails from NLIMS
|
|
197
|
+
save_status_trails_from_nlims(order, order_dto)
|
|
198
|
+
|
|
199
|
+
# Update results if present
|
|
194
200
|
update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
|
|
195
201
|
|
|
196
202
|
order
|
|
@@ -200,6 +206,11 @@ module Lab
|
|
|
200
206
|
logger.debug("Updating order ##{order_dto['_id']}")
|
|
201
207
|
order = OrdersService.update_order(order_id, order_dto.to_order_service_params(patient_id: patient.patient_id)
|
|
202
208
|
.merge(force_update: 'true'))
|
|
209
|
+
|
|
210
|
+
# Extract and save status trails from NLIMS
|
|
211
|
+
save_status_trails_from_nlims(order, order_dto)
|
|
212
|
+
|
|
213
|
+
# Update results if present
|
|
203
214
|
update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
|
|
204
215
|
|
|
205
216
|
order
|
|
@@ -290,6 +301,165 @@ module Lab
|
|
|
290
301
|
"#{id}:#{first_name} #{last_name}:#{phone_number}"
|
|
291
302
|
end
|
|
292
303
|
|
|
304
|
+
def save_status_trails_from_nlims(order, order_dto)
|
|
305
|
+
logger.debug("Saving status trails from NLIMS for order ##{order['order_id'] || order[:order_id]}")
|
|
306
|
+
logger.debug("Order DTO keys: #{order_dto.keys.inspect}")
|
|
307
|
+
logger.debug("Order DTO sample_statuses type: #{order_dto[:sample_statuses].class}")
|
|
308
|
+
logger.debug("Order DTO sample_statuses content: #{order_dto[:sample_statuses].inspect}")
|
|
309
|
+
|
|
310
|
+
# Extract order status trail from sample_statuses (NLIMS uses sample_statuses for order status)
|
|
311
|
+
# Note: sample_statuses is an array of single-key hashes
|
|
312
|
+
if order_dto[:sample_statuses].is_a?(Array)
|
|
313
|
+
logger.debug("Found sample_statuses: #{order_dto[:sample_statuses].size} entries")
|
|
314
|
+
save_order_status_trails(order, order_dto[:sample_statuses])
|
|
315
|
+
else
|
|
316
|
+
logger.warn("No sample_statuses found or not an Array: #{order_dto[:sample_statuses].class}")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Extract test status trails from test_statuses
|
|
320
|
+
if order_dto['test_statuses'].is_a?(Hash)
|
|
321
|
+
logger.debug("Found test_statuses: #{order_dto['test_statuses'].keys.size} entries")
|
|
322
|
+
save_test_status_trails(order, order_dto['test_statuses'])
|
|
323
|
+
else
|
|
324
|
+
logger.debug("No test_statuses found or not a Hash: #{order_dto['test_statuses'].class}")
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def save_order_status_trails(order, sample_statuses)
|
|
329
|
+
logger.debug("Saving order status trails for order ##{order['order_id'] || order[:order_id]}")
|
|
330
|
+
logger.debug("Sample statuses: #{sample_statuses.inspect}")
|
|
331
|
+
|
|
332
|
+
# Find concept
|
|
333
|
+
order_status_concept = ConceptName.find_by(name: 'Lab Order Status')&.concept
|
|
334
|
+
unless order_status_concept
|
|
335
|
+
logger.error('Lab Order Status concept not found')
|
|
336
|
+
return
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
order_id = order['order_id'] || order[:order_id] || order['id'] || order[:id]
|
|
340
|
+
lab_order = Lab::LabOrder.find_by(order_id: order_id)
|
|
341
|
+
unless lab_order
|
|
342
|
+
logger.error("Order not found: #{order_id}")
|
|
343
|
+
return
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# sample_statuses is an array of single-key hashes like:
|
|
347
|
+
# [{ "20260225120000" => { "status" => "Drawn", ... } }, { "20260225130000" => { ... } }]
|
|
348
|
+
sample_statuses.each do |trail_entry|
|
|
349
|
+
# Each trail_entry is a hash with one timestamp key
|
|
350
|
+
trail_entry.each_pair do |timestamp_key, status_data|
|
|
351
|
+
next unless status_data.is_a?(Hash) && status_data['status']
|
|
352
|
+
|
|
353
|
+
# Parse timestamp (format: YYYYMMDDHHmmss) - already in local timezone from NLIMS conversion
|
|
354
|
+
# Use Time.zone.strptime to prevent Rails from converting timezone during save
|
|
355
|
+
begin
|
|
356
|
+
timestamp = Time.zone.strptime(timestamp_key, '%Y%m%d%H%M%S')
|
|
357
|
+
rescue StandardError => e
|
|
358
|
+
logger.warn("Failed to parse timestamp '#{timestamp_key}': #{e.message}")
|
|
359
|
+
next
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Skip if this status has already been recorded for this order (regardless of timestamp)
|
|
363
|
+
if Observation.unscoped.exists?(
|
|
364
|
+
person_id: lab_order.patient_id,
|
|
365
|
+
order_id: order_id,
|
|
366
|
+
concept_id: order_status_concept.concept_id,
|
|
367
|
+
value_text: status_data['status'],
|
|
368
|
+
voided: 0
|
|
369
|
+
)
|
|
370
|
+
logger.debug("Order status already recorded: #{status_data['status']} for order ##{order_id}")
|
|
371
|
+
next
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
updated_by = status_data['updated_by'] || {}
|
|
375
|
+
|
|
376
|
+
begin
|
|
377
|
+
Observation.create!(
|
|
378
|
+
person_id: lab_order.patient_id,
|
|
379
|
+
encounter_id: lab_order.encounter_id,
|
|
380
|
+
order_id: order_id,
|
|
381
|
+
concept_id: order_status_concept.concept_id,
|
|
382
|
+
value_text: status_data['status'], # Store status as text
|
|
383
|
+
obs_datetime: timestamp,
|
|
384
|
+
comments: updated_by.to_json,
|
|
385
|
+
creator: User.current&.user_id || 1,
|
|
386
|
+
date_created: Time.now,
|
|
387
|
+
uuid: SecureRandom.uuid
|
|
388
|
+
)
|
|
389
|
+
logger.info("Created order status trail: #{status_data['status']} at #{timestamp}")
|
|
390
|
+
rescue StandardError => e
|
|
391
|
+
logger.error("Failed to save order status trail: #{e.message}")
|
|
392
|
+
logger.error(" Order ID: #{order_id}")
|
|
393
|
+
logger.error(" Status: #{status_data['status']}")
|
|
394
|
+
logger.error(" Timestamp: #{timestamp}")
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def save_test_status_trails(order, test_statuses)
|
|
401
|
+
# Find test status concept
|
|
402
|
+
test_status_concept = ConceptName.find_by(name: 'Lab Test Status')&.concept
|
|
403
|
+
unless test_status_concept
|
|
404
|
+
logger.error('Lab Test Status concept not found')
|
|
405
|
+
return
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
test_statuses.each do |test_name, statuses|
|
|
409
|
+
next unless statuses.is_a?(Hash)
|
|
410
|
+
|
|
411
|
+
# Find the test by name
|
|
412
|
+
test_concept = Utils.find_concept_by_name(Utils.translate_test_name(test_name))
|
|
413
|
+
next unless test_concept
|
|
414
|
+
|
|
415
|
+
test = Lab::LabTest.find_by(order_id: order['order_id'], value_coded: test_concept.concept_id)
|
|
416
|
+
next unless test
|
|
417
|
+
|
|
418
|
+
# Process each status in the trail
|
|
419
|
+
statuses.each do |timestamp_key, status_data|
|
|
420
|
+
next unless status_data.is_a?(Hash) && status_data['status']
|
|
421
|
+
|
|
422
|
+
# Parse timestamp (format: YYYYMMDDHHmmss) - already in local timezone from NLIMS conversion
|
|
423
|
+
# Use Time.zone.strptime to prevent Rails from converting timezone during save
|
|
424
|
+
begin
|
|
425
|
+
timestamp = Time.zone.strptime(timestamp_key, '%Y%m%d%H%M%S')
|
|
426
|
+
rescue StandardError => e
|
|
427
|
+
logger.warn("Failed to parse timestamp '#{timestamp_key}': #{e.message}")
|
|
428
|
+
next
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Skip if this status has already been recorded for this test (regardless of timestamp)
|
|
432
|
+
next if Observation.unscoped.exists?(
|
|
433
|
+
person_id: test.person_id,
|
|
434
|
+
obs_group_id: test.obs_id,
|
|
435
|
+
concept_id: test_status_concept.concept_id,
|
|
436
|
+
value_text: status_data['status'],
|
|
437
|
+
voided: 0
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
updated_by = status_data['updated_by'] || {}
|
|
441
|
+
|
|
442
|
+
begin
|
|
443
|
+
Observation.create!(
|
|
444
|
+
person_id: test.person_id,
|
|
445
|
+
encounter_id: test.encounter_id,
|
|
446
|
+
obs_group_id: test.obs_id,
|
|
447
|
+
concept_id: test_status_concept.concept_id,
|
|
448
|
+
value_text: status_data['status'], # Store status as text
|
|
449
|
+
obs_datetime: timestamp,
|
|
450
|
+
comments: updated_by.to_json,
|
|
451
|
+
creator: User.current&.user_id || 1,
|
|
452
|
+
date_created: Time.now,
|
|
453
|
+
uuid: SecureRandom.uuid
|
|
454
|
+
)
|
|
455
|
+
logger.info("Created test status trail: #{status_data['status']} at #{timestamp} for #{test_name}")
|
|
456
|
+
rescue StandardError => e
|
|
457
|
+
logger.error("Failed to save test status trail for #{test_name}: #{e.message}")
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
293
463
|
def save_failed_import(order_dto, reason, diff = nil)
|
|
294
464
|
logger.info("Failed to import LIMS order ##{order_dto[:tracking_number]} due to '#{reason}'")
|
|
295
465
|
LimsFailedImport.create!(lims_id: order_dto[:_id],
|