his_emr_api_lab 2.1.8 → 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 +2 -1
- data/app/jobs/lab/process_lab_result_job.rb +1 -1
- 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 +73 -2
- data/app/services/lab/concepts_service.rb +12 -7
- data/app/services/lab/lims/api/rest_api.rb +86 -5
- data/app/services/lab/lims/pull_worker.rb +169 -0
- data/app/services/lab/metadata.rb +1 -0
- data/app/services/lab/orders_service.rb +224 -24
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cfef9418fc9d94d233f24d04883623d67e0c59c2aacea66bc15d3cca61192f78
|
|
4
|
+
data.tar.gz: 2d6324bf11f30c87398819042e3a0835275596961bfd838d088a63ae7a4f93c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7eaa989ae6027aaba3bcd29e90136d1f93eda66854f7ee5bf84453420e0c66a40a928e460e00ee48bdee3fac8e2de05b7c030a7c71c8d4a6bf9457704dc7c6f
|
|
7
|
+
data.tar.gz: 16048c52a25d2876d79d6b3526b13625c67de47c44717de48910f905a75bb02dbd1cd48d7ce22e8b58947e61a1cb603340ea3bbdb1cce7536bb1774f41a72d65
|
|
@@ -52,7 +52,8 @@ module Lab
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def order_status
|
|
55
|
-
order_params = params.permit(:tracking_number, :status, :status_time, :comments
|
|
55
|
+
order_params = params.permit(:tracking_number, :status, :status_time, :comments, :status_id,
|
|
56
|
+
updated_by: [:first_name, :last_name, :id, :phone_number])
|
|
56
57
|
OrdersService.update_order_status(order_params)
|
|
57
58
|
render json: { message: "Status for order #{order_params['tracking_number']} successfully updated" }, status: :ok
|
|
58
59
|
end
|
|
@@ -7,7 +7,7 @@ module Lab
|
|
|
7
7
|
queue_as :default
|
|
8
8
|
def perform(results_obs_id, serializer, result_enter_by)
|
|
9
9
|
Rails.logger.info("Lab::ProcessLabResultJob: Processing result completion for #{serializer}")
|
|
10
|
-
results_obs = Lab::LabResult.find(results_obs_id)
|
|
10
|
+
results_obs = Lab::LabResult.unscoped.find(results_obs_id)
|
|
11
11
|
Lab::ResultsService.process_result_completion(results_obs, serializer, result_enter_by)
|
|
12
12
|
end
|
|
13
13
|
end
|
data/app/models/lab/lab_order.rb
CHANGED
|
@@ -8,6 +8,11 @@ module Lab
|
|
|
8
8
|
|
|
9
9
|
-> { where(concept:) }
|
|
10
10
|
end
|
|
11
|
+
|
|
12
|
+
# Cache the concept ID to avoid lookups in association scopes
|
|
13
|
+
def order_status_concept_id
|
|
14
|
+
@order_status_concept_id ||= ConceptName.find_by(name: 'Lab Order Status')&.concept_id
|
|
15
|
+
end
|
|
11
16
|
end
|
|
12
17
|
|
|
13
18
|
has_many :tests,
|
|
@@ -44,12 +49,20 @@ module Lab
|
|
|
44
49
|
class_name: '::Lab::LimsOrderMapping',
|
|
45
50
|
foreign_key: :order_id
|
|
46
51
|
|
|
52
|
+
# Status trails are stored as observations with concept 'Lab Order Status'
|
|
53
|
+
has_many :status_trail_observations,
|
|
54
|
+
lambda {
|
|
55
|
+
unscoped.where(voided: 0, concept_id: Lab::LabOrder.order_status_concept_id).order(obs_datetime: :asc)
|
|
56
|
+
},
|
|
57
|
+
class_name: 'Observation',
|
|
58
|
+
foreign_key: :order_id
|
|
59
|
+
|
|
47
60
|
default_scope do
|
|
48
61
|
joins(:order_type)
|
|
49
62
|
.merge(OrderType.where(name: [
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
Lab::Metadata::ORDER_TYPE_NAME,
|
|
64
|
+
Lab::Metadata::HTS_ORDER_TYPE_NAME
|
|
65
|
+
]))
|
|
53
66
|
.where.not(concept_id: ConceptName.where(name: 'Tests ordered').select(:concept_id))
|
|
54
67
|
end
|
|
55
68
|
|
|
@@ -57,11 +70,13 @@ module Lab
|
|
|
57
70
|
scope :not_drawn, -> { where(concept_id: ConceptName.where(name: 'Unknown').select(:concept_id)) }
|
|
58
71
|
|
|
59
72
|
def self.prefetch_relationships
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
# NOTE: status_trail_observations and test results are not preloaded due to
|
|
74
|
+
# Rails limitations with eager loading unscoped associations. They load on-demand instead.
|
|
75
|
+
preload(:reason_for_test,
|
|
76
|
+
:requesting_clinician,
|
|
77
|
+
:target_lab,
|
|
78
|
+
:comment_to_fulfiller,
|
|
79
|
+
:tests)
|
|
65
80
|
end
|
|
66
81
|
end
|
|
67
82
|
end
|
data/app/models/lab/lab_test.rb
CHANGED
|
@@ -6,14 +6,33 @@ module Lab
|
|
|
6
6
|
where(concept: ConceptName.where(name: Lab::Metadata::TEST_TYPE_CONCEPT_NAME))
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
+
# Cache the concept IDs as class methods to avoid lookups in association scopes
|
|
10
|
+
def self.test_status_concept_id
|
|
11
|
+
@test_status_concept_id ||= ConceptName.find_by(name: 'Lab Test Status')&.concept_id
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.test_result_concept_id
|
|
15
|
+
@test_result_concept_id ||= ConceptName.find_by(name: Lab::Metadata::TEST_RESULT_CONCEPT_NAME)&.concept_id
|
|
16
|
+
end
|
|
17
|
+
|
|
9
18
|
has_one :result,
|
|
10
|
-
-> { where(
|
|
19
|
+
-> { unscoped.where(voided: 0, concept_id: Lab::LabTest.test_result_concept_id) },
|
|
11
20
|
class_name: 'Lab::LabResult',
|
|
12
21
|
foreign_key: :obs_group_id
|
|
13
22
|
|
|
23
|
+
# Status trails are stored as observations with concept 'Lab Test Status'
|
|
24
|
+
# They are linked via obs_group_id (this test obs is the parent)
|
|
25
|
+
has_many :status_trail_observations,
|
|
26
|
+
lambda {
|
|
27
|
+
unscoped.where(voided: 0, concept_id: Lab::LabTest.test_status_concept_id).order(obs_datetime: :asc)
|
|
28
|
+
},
|
|
29
|
+
class_name: 'Observation',
|
|
30
|
+
foreign_key: :obs_group_id,
|
|
31
|
+
primary_key: :obs_id
|
|
32
|
+
|
|
14
33
|
def void(reason)
|
|
15
34
|
result&.void(reason)
|
|
16
|
-
super
|
|
35
|
+
super
|
|
17
36
|
end
|
|
18
37
|
end
|
|
19
38
|
end
|
|
@@ -36,8 +36,10 @@ module Lab
|
|
|
36
36
|
name: concept_name(reason_for_test&.value_coded)
|
|
37
37
|
},
|
|
38
38
|
delivery_mode: order&.lims_acknowledgement_status&.acknowledgement_type,
|
|
39
|
+
order_status: latest_order_status(order),
|
|
40
|
+
order_status_trail: serialize_order_status_trail(order),
|
|
39
41
|
tests: tests.map do |test|
|
|
40
|
-
result_obs = test.
|
|
42
|
+
result_obs = test.result
|
|
41
43
|
|
|
42
44
|
{
|
|
43
45
|
id: test.obs_id,
|
|
@@ -45,7 +47,9 @@ module Lab
|
|
|
45
47
|
uuid: test.uuid,
|
|
46
48
|
name: concept_name(test.value_coded),
|
|
47
49
|
test_method: test_method(order, test.value_coded),
|
|
48
|
-
result: result_obs && ResultSerializer.serialize(result_obs)
|
|
50
|
+
result: result_obs && ResultSerializer.serialize(result_obs),
|
|
51
|
+
test_status: latest_test_status(test),
|
|
52
|
+
test_status_trail: serialize_test_status_trail(test)
|
|
49
53
|
}
|
|
50
54
|
end
|
|
51
55
|
}
|
|
@@ -74,5 +78,72 @@ module Lab
|
|
|
74
78
|
.select(:concept_id)
|
|
75
79
|
LabTest.unscoped.where(concept:, order:, voided: true)
|
|
76
80
|
end
|
|
81
|
+
|
|
82
|
+
def self.latest_order_status(order)
|
|
83
|
+
# Query obs table for latest order status
|
|
84
|
+
latest_obs = order.status_trail_observations.last
|
|
85
|
+
return nil unless latest_obs
|
|
86
|
+
|
|
87
|
+
updated_by = parse_comments_json(latest_obs.comments)
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
status_id: 0, # status_id not used with text values
|
|
91
|
+
status: latest_obs.value_text,
|
|
92
|
+
timestamp: latest_obs.obs_datetime,
|
|
93
|
+
updated_by: updated_by
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.serialize_order_status_trail(order)
|
|
98
|
+
# Query obs table for order status trail
|
|
99
|
+
order.status_trail_observations.map do |obs|
|
|
100
|
+
updated_by = parse_comments_json(obs.comments)
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
status_id: 0, # status_id not used with text values
|
|
104
|
+
status: obs.value_text,
|
|
105
|
+
timestamp: obs.obs_datetime,
|
|
106
|
+
updated_by: updated_by
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.latest_test_status(test)
|
|
112
|
+
# Query obs table for latest test status
|
|
113
|
+
latest_obs = test.status_trail_observations.last
|
|
114
|
+
return nil unless latest_obs
|
|
115
|
+
|
|
116
|
+
updated_by = parse_comments_json(latest_obs.comments)
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
status_id: 0, # status_id not used with text values
|
|
120
|
+
status: latest_obs.value_text,
|
|
121
|
+
timestamp: latest_obs.obs_datetime,
|
|
122
|
+
updated_by: updated_by
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.serialize_test_status_trail(test)
|
|
127
|
+
# Query obs table for test status trail
|
|
128
|
+
test.status_trail_observations.map do |obs|
|
|
129
|
+
updated_by = parse_comments_json(obs.comments)
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
status_id: 0, # status_id not used with text values
|
|
133
|
+
status: obs.value_text,
|
|
134
|
+
timestamp: obs.obs_datetime,
|
|
135
|
+
updated_by: updated_by
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Helper to parse updated_by from obs comments field
|
|
141
|
+
def self.parse_comments_json(comments)
|
|
142
|
+
return {} if comments.blank?
|
|
143
|
+
|
|
144
|
+
JSON.parse(comments)
|
|
145
|
+
rescue JSON::ParserError
|
|
146
|
+
{}
|
|
147
|
+
end
|
|
77
148
|
end
|
|
78
149
|
end
|
|
@@ -34,10 +34,11 @@ module Lab
|
|
|
34
34
|
|
|
35
35
|
unless specimen_type
|
|
36
36
|
return ActiveRecord::Base.connection.select_all <<~SQL
|
|
37
|
-
SELECT ca.concept_id, ca.value_reference as name, ca2.value_reference as nlims_code
|
|
37
|
+
SELECT ca.concept_id, ca.value_reference as name, ca2.value_reference as nlims_code, c.uuid
|
|
38
38
|
FROM concept_attribute ca
|
|
39
39
|
INNER JOIN concept_attribute ca2 ON ca.concept_id = ca2.concept_id
|
|
40
40
|
AND ca2.attribute_type_id = #{ConceptAttributeType.nlims_code.concept_attribute_type_id}
|
|
41
|
+
INNER JOIN concept c ON c.concept_id = ca.concept_id
|
|
41
42
|
WHERE ca.attribute_type_id = #{ConceptAttributeType.test_catalogue_name.concept_attribute_type_id}
|
|
42
43
|
AND ca.concept_id IN (#{test_types.select(:concept_id).to_sql})
|
|
43
44
|
GROUP BY ca.concept_id
|
|
@@ -56,10 +57,11 @@ module Lab
|
|
|
56
57
|
)
|
|
57
58
|
|
|
58
59
|
return ActiveRecord::Base.connection.select_all <<~SQL
|
|
59
|
-
SELECT ca.concept_id, ca.value_reference as name, ca2.value_reference as nlims_code
|
|
60
|
+
SELECT ca.concept_id, ca.value_reference as name, ca2.value_reference as nlims_code, c.uuid
|
|
60
61
|
FROM concept_attribute ca
|
|
61
62
|
INNER JOIN concept_attribute ca2 ON ca.concept_id = ca2.concept_id
|
|
62
63
|
AND ca2.attribute_type_id = #{ConceptAttributeType.nlims_code.concept_attribute_type_id}
|
|
64
|
+
INNER JOIN concept c ON c.concept_id = ca.concept_id
|
|
63
65
|
WHERE ca.attribute_type_id = #{ConceptAttributeType.test_catalogue_name.concept_attribute_type_id}
|
|
64
66
|
AND ca.concept_id IN (#{concept_set.select(:concept_set).to_sql})
|
|
65
67
|
GROUP BY ca.concept_id
|
|
@@ -72,10 +74,11 @@ module Lab
|
|
|
72
74
|
|
|
73
75
|
unless test_type
|
|
74
76
|
return ActiveRecord::Base.connection.select_all <<~SQL
|
|
75
|
-
SELECT ca.concept_id, ca.value_reference as name, ca2.value_reference as nlims_code
|
|
77
|
+
SELECT ca.concept_id, ca.value_reference as name, ca2.value_reference as nlims_code, c.uuid
|
|
76
78
|
FROM concept_attribute ca
|
|
77
79
|
INNER JOIN concept_attribute ca2 ON ca.concept_id = ca2.concept_id
|
|
78
80
|
AND ca2.attribute_type_id = #{ConceptAttributeType.nlims_code.concept_attribute_type_id}
|
|
81
|
+
INNER JOIN concept c ON c.concept_id = ca.concept_id
|
|
79
82
|
WHERE ca.attribute_type_id = #{ConceptAttributeType.test_catalogue_name.concept_attribute_type_id}
|
|
80
83
|
AND ca.concept_id IN (#{specimen_types.select(:concept_id).to_sql})
|
|
81
84
|
GROUP BY ca.concept_id
|
|
@@ -94,10 +97,11 @@ module Lab
|
|
|
94
97
|
)
|
|
95
98
|
|
|
96
99
|
return ActiveRecord::Base.connection.select_all <<~SQL
|
|
97
|
-
SELECT ca.concept_id, ca.value_reference as name, ca2.value_reference as nlims_code
|
|
100
|
+
SELECT ca.concept_id, ca.value_reference as name, ca2.value_reference as nlims_code, c.uuid
|
|
98
101
|
FROM concept_attribute ca
|
|
99
102
|
INNER JOIN concept_attribute ca2 ON ca.concept_id = ca2.concept_id
|
|
100
103
|
AND ca2.attribute_type_id = #{ConceptAttributeType.nlims_code.concept_attribute_type_id}
|
|
104
|
+
INNER JOIN concept c ON c.concept_id = ca.concept_id
|
|
101
105
|
WHERE ca.attribute_type_id = #{ConceptAttributeType.test_catalogue_name.concept_attribute_type_id}
|
|
102
106
|
AND ca.concept_id IN (#{concept_set.pluck(:concept_id).push(0).join(',')})
|
|
103
107
|
GROUP BY ca.concept_id
|
|
@@ -114,15 +118,16 @@ module Lab
|
|
|
114
118
|
measures = ConceptSet.find_members_by_name(Lab::Metadata::TEST_RESULT_INDICATOR_CONCEPT_NAME)
|
|
115
119
|
.select(:concept_id)
|
|
116
120
|
|
|
117
|
-
sets = ConceptSet.where(concept_set:
|
|
121
|
+
sets = ConceptSet.where(concept_set: test, concept_id: measures)
|
|
118
122
|
|
|
119
123
|
return ActiveRecord::Base.connection.select_all <<~SQL
|
|
120
|
-
SELECT ca.concept_id, ca.value_reference as name, ca2.value_reference as nlims_code
|
|
124
|
+
SELECT ca.concept_id, ca.value_reference as name, ca2.value_reference as nlims_code, c.uuid
|
|
121
125
|
FROM concept_attribute ca
|
|
122
126
|
INNER JOIN concept_attribute ca2 ON ca.concept_id = ca2.concept_id
|
|
123
127
|
AND ca2.attribute_type_id = #{ConceptAttributeType.nlims_code.concept_attribute_type_id}
|
|
128
|
+
INNER JOIN concept c ON c.concept_id = ca.concept_id
|
|
124
129
|
WHERE ca.attribute_type_id = #{ConceptAttributeType.test_catalogue_name.concept_attribute_type_id}
|
|
125
|
-
AND ca.concept_id IN (#{sets.pluck(:
|
|
130
|
+
AND ca.concept_id IN (#{sets.pluck(:concept_id).push(0).join(',')})
|
|
126
131
|
GROUP BY ca.concept_id
|
|
127
132
|
SQL
|
|
128
133
|
end
|
|
@@ -77,15 +77,82 @@ module Lab
|
|
|
77
77
|
def consume_orders(*_args, patient_id: nil, **_kwargs)
|
|
78
78
|
orders_pending_updates(patient_id).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
|
|
|
@@ -550,6 +617,20 @@ module Lab
|
|
|
550
617
|
|
|
551
618
|
orders
|
|
552
619
|
end
|
|
620
|
+
|
|
621
|
+
# Converts ISO 8601 timestamp to YYYYMMDDHHmmss format
|
|
622
|
+
def convert_timestamp_to_key(timestamp)
|
|
623
|
+
return timestamp if timestamp.nil? || timestamp.empty?
|
|
624
|
+
|
|
625
|
+
begin
|
|
626
|
+
# Parse ISO 8601 timestamp and format as YYYYMMDDHHmmss
|
|
627
|
+
Time.parse(timestamp).strftime('%Y%m%d%H%M%S')
|
|
628
|
+
rescue StandardError => e
|
|
629
|
+
Rails.logger.warn("Failed to parse timestamp '#{timestamp}': #{e.message}")
|
|
630
|
+
# Fallback: remove all non-digits
|
|
631
|
+
timestamp.to_s.gsub(/\D/, '')
|
|
632
|
+
end
|
|
633
|
+
end
|
|
553
634
|
end
|
|
554
635
|
end
|
|
555
636
|
end
|
|
@@ -191,6 +191,11 @@ module Lab
|
|
|
191
191
|
def create_order(patient, order_dto)
|
|
192
192
|
logger.debug("Creating order ##{order_dto['_id']}")
|
|
193
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
|
|
194
199
|
update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
|
|
195
200
|
|
|
196
201
|
order
|
|
@@ -200,6 +205,11 @@ module Lab
|
|
|
200
205
|
logger.debug("Updating order ##{order_dto['_id']}")
|
|
201
206
|
order = OrdersService.update_order(order_id, order_dto.to_order_service_params(patient_id: patient.patient_id)
|
|
202
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
|
|
203
213
|
update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
|
|
204
214
|
|
|
205
215
|
order
|
|
@@ -290,6 +300,165 @@ module Lab
|
|
|
290
300
|
"#{id}:#{first_name} #{last_name}:#{phone_number}"
|
|
291
301
|
end
|
|
292
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
|
+
|
|
293
462
|
def save_failed_import(order_dto, reason, diff = nil)
|
|
294
463
|
logger.info("Failed to import LIMS order ##{order_dto[:tracking_number]} due to '#{reason}'")
|
|
295
464
|
LimsFailedImport.create!(lims_id: order_dto[:_id],
|
|
@@ -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'
|
|
@@ -60,8 +60,14 @@ module Lab
|
|
|
60
60
|
|
|
61
61
|
attach_test_method(order, order_params) if order_params[:test_method]
|
|
62
62
|
|
|
63
|
+
# Create initial order status trail
|
|
64
|
+
create_initial_order_status_trail(order)
|
|
65
|
+
|
|
63
66
|
Lab::TestsService.create_tests(order, order_params[:date], order_params[:tests])
|
|
64
67
|
|
|
68
|
+
# Reload order to include status trails and tests
|
|
69
|
+
order = Lab::LabOrder.prefetch_relationships.find(order.order_id)
|
|
70
|
+
|
|
65
71
|
Lab::LabOrderSerializer.serialize_order(
|
|
66
72
|
order, requesting_clinician: add_requesting_clinician(order, order_params),
|
|
67
73
|
reason_for_test: add_reason_for_test(order, order_params),
|
|
@@ -102,14 +108,16 @@ module Lab
|
|
|
102
108
|
|
|
103
109
|
if reason_for_test
|
|
104
110
|
Rails.logger.debug("Updating reason for test on order ##{order.order_id}")
|
|
105
|
-
update_reason_for_test(order, Concept.find(reason_for_test)&.id,
|
|
111
|
+
update_reason_for_test(order, Concept.find(reason_for_test)&.id,
|
|
112
|
+
force_update: params.fetch('force_update', false))
|
|
106
113
|
end
|
|
107
114
|
|
|
108
115
|
Lab::LabOrderSerializer.serialize_order(order)
|
|
109
116
|
end
|
|
110
117
|
|
|
111
118
|
def void_order(order_id, reason)
|
|
112
|
-
order = Lab::LabOrder.includes(%i[requesting_clinician reason_for_test target_lab comment_to_fulfiller],
|
|
119
|
+
order = Lab::LabOrder.includes(%i[requesting_clinician reason_for_test target_lab comment_to_fulfiller],
|
|
120
|
+
tests: [:result])
|
|
113
121
|
.find(order_id)
|
|
114
122
|
|
|
115
123
|
order.requesting_clinician&.void(reason)
|
|
@@ -140,14 +148,55 @@ module Lab
|
|
|
140
148
|
value_text: order_params['status'],
|
|
141
149
|
creator: User.current.id
|
|
142
150
|
)
|
|
151
|
+
|
|
152
|
+
# Save order status trail if available
|
|
153
|
+
save_order_status_trail(order, order_params) if order_params['status']
|
|
143
154
|
end
|
|
144
155
|
create_rejection_notification(order_params) if order_params['status'] == 'test-rejected'
|
|
145
156
|
end
|
|
146
157
|
|
|
147
158
|
def update_order_result(order_params)
|
|
148
|
-
|
|
159
|
+
# Extract tracking number from nested structure if present
|
|
160
|
+
tracking_number = order_params['tracking_number'] || order_params.dig('order', 'tracking_number')
|
|
161
|
+
order = find_order(tracking_number)
|
|
162
|
+
|
|
149
163
|
order_dto = Lab::Lims::OrderSerializer.serialize_order(order)
|
|
150
|
-
|
|
164
|
+
|
|
165
|
+
# Handle results if present in the old format
|
|
166
|
+
patch_order_dto_with_lims_results!(order_dto, order_params['results']) if order_params['results']
|
|
167
|
+
|
|
168
|
+
# Handle test results in NLIMS format
|
|
169
|
+
if order_params['tests']
|
|
170
|
+
# Extract test results from NLIMS tests payload
|
|
171
|
+
test_results = {}
|
|
172
|
+
order_params['tests'].each do |test_data|
|
|
173
|
+
test_name = test_data.dig('test_type', 'name')
|
|
174
|
+
next unless test_name && test_data['test_results']
|
|
175
|
+
|
|
176
|
+
test_results[test_name] = {
|
|
177
|
+
'results' => test_data['test_results'].each_with_object({}) do |result, formatted|
|
|
178
|
+
measure_name = result.dig('measure', 'name')
|
|
179
|
+
result_value = result.dig('result', 'value')
|
|
180
|
+
next unless measure_name && result_value
|
|
181
|
+
|
|
182
|
+
formatted[measure_name] = { 'result_value' => result_value }
|
|
183
|
+
end,
|
|
184
|
+
'result_date' => test_data['test_results'].first&.dig('result', 'result_date'),
|
|
185
|
+
'result_entered_by' => {}
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
patch_order_dto_with_lims_results!(order_dto, test_results) unless test_results.empty?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Save order status trail if available from NLIMS
|
|
193
|
+
if order_params['order'] && order_params['order']['status_trail']
|
|
194
|
+
save_order_status_trails_from_nlims(order, order_params['order']['status_trail'])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Save test status trails if available from NLIMS
|
|
198
|
+
save_test_status_trails_from_nlims(order, order_params['tests']) if order_params['tests']
|
|
199
|
+
|
|
151
200
|
Lab::Lims::PullWorker.new(nil).process_order(order_dto)
|
|
152
201
|
end
|
|
153
202
|
|
|
@@ -160,13 +209,15 @@ module Lab
|
|
|
160
209
|
last_order_date: Lab::LabOrder.last&.start_date&.to_date,
|
|
161
210
|
lab_orders: []
|
|
162
211
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
212
|
+
if include_data
|
|
213
|
+
data[:lab_orders] = orders.map do |order|
|
|
214
|
+
Lab::LabOrderSerializer.serialize_order(
|
|
215
|
+
order, requesting_clinician: order.requesting_clinician,
|
|
216
|
+
reason_for_test: order.reason_for_test,
|
|
217
|
+
target_lab: order.target_lab
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
170
221
|
data
|
|
171
222
|
end
|
|
172
223
|
|
|
@@ -174,14 +225,14 @@ module Lab
|
|
|
174
225
|
|
|
175
226
|
def create_rejection_notification(order_params)
|
|
176
227
|
order = find_order order_params['tracking_number']
|
|
177
|
-
data = {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
228
|
+
data = { type: 'LIMS',
|
|
229
|
+
specimen: ConceptName.find_by(concept_id: order.concept_id)&.name,
|
|
230
|
+
accession_number: order&.accession_number,
|
|
231
|
+
order_date: order&.start_date,
|
|
232
|
+
arv_number: find_arv_number(order.patient_id),
|
|
233
|
+
patient_id: result.person_id,
|
|
234
|
+
ordered_by: order&.provider&.person&.name,
|
|
235
|
+
rejection_reason: order_params['comments'] }.as_json
|
|
185
236
|
NotificationService.new.create_notification('LIMS', data)
|
|
186
237
|
end
|
|
187
238
|
|
|
@@ -235,7 +286,9 @@ module Lab
|
|
|
235
286
|
encounter.encounter_datetime = order_params[:date] || Date.today
|
|
236
287
|
encounter.visit = Visit.find_by_uuid(visit) if Encounter.column_names.include?('visit_id')
|
|
237
288
|
encounter.provider_id = User.current&.person&.id if Encounter.column_names.include?('provider_id')
|
|
238
|
-
|
|
289
|
+
if Encounter.column_names.include?('program_id') && order_params[:program_id].present?
|
|
290
|
+
encounter.program_id = order_params[:program_id]
|
|
291
|
+
end
|
|
239
292
|
encounter.save!
|
|
240
293
|
encounter.reload
|
|
241
294
|
end
|
|
@@ -247,7 +300,6 @@ module Lab
|
|
|
247
300
|
|
|
248
301
|
concept = params.dig(:specimen, :concept)
|
|
249
302
|
concept ||= params.dig(:specimen, :concept_id)
|
|
250
|
-
concept ||= unknown_concept_id
|
|
251
303
|
|
|
252
304
|
order_type = nil
|
|
253
305
|
order_type = OrderType.find_by_order_type_id!(params[:order_type_id])&.id if params[:order_type_id].present?
|
|
@@ -260,7 +312,8 @@ module Lab
|
|
|
260
312
|
order.date_created = params[:date]&.to_date || Date.today if order.respond_to?(:date_created)
|
|
261
313
|
order.start_date = params[:date]&.to_date || Date.today if order.respond_to?(:start_date)
|
|
262
314
|
order.auto_expire_date = params[:end_date]
|
|
263
|
-
|
|
315
|
+
# NOTE: comment_to_fulfiller is a has_one association, not a field
|
|
316
|
+
# It will be created via add_comment_to_fulfiller method
|
|
264
317
|
order.accession_number = access_number
|
|
265
318
|
order.orderer = User.current&.user_id
|
|
266
319
|
|
|
@@ -348,7 +401,7 @@ module Lab
|
|
|
348
401
|
end
|
|
349
402
|
|
|
350
403
|
def unknown_concept_id
|
|
351
|
-
ConceptName.find_by_name!('Unknown').
|
|
404
|
+
ConceptName.find_by_name!('Unknown').concept
|
|
352
405
|
end
|
|
353
406
|
|
|
354
407
|
def update_reason_for_test(order, concept_id, force_update: false)
|
|
@@ -356,7 +409,10 @@ module Lab
|
|
|
356
409
|
|
|
357
410
|
return if order.reason_for_test&.value_coded == concept_id
|
|
358
411
|
|
|
359
|
-
|
|
412
|
+
if order.reason_for_test&.value_coded && !force_update
|
|
413
|
+
raise InvalidParameterError,
|
|
414
|
+
"Can't change reason for test once set"
|
|
415
|
+
end
|
|
360
416
|
|
|
361
417
|
order.reason_for_test&.delete
|
|
362
418
|
date = order.start_date if order.respond_to?(:start_date)
|
|
@@ -369,6 +425,150 @@ module Lab
|
|
|
369
425
|
obs.void('New Status Received from LIMS')
|
|
370
426
|
end
|
|
371
427
|
end
|
|
428
|
+
|
|
429
|
+
def create_initial_order_status_trail(order)
|
|
430
|
+
create_order_status_observation(
|
|
431
|
+
order: order,
|
|
432
|
+
status: 'Drawn',
|
|
433
|
+
timestamp: order.start_date || order.date_created || Time.now,
|
|
434
|
+
updated_by: {
|
|
435
|
+
'first_name' => User.current&.person&.names&.first&.given_name,
|
|
436
|
+
'last_name' => User.current&.person&.names&.first&.family_name,
|
|
437
|
+
'id' => User.current&.user_id&.to_s,
|
|
438
|
+
'phone_number' => nil
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
rescue StandardError => e
|
|
442
|
+
Rails.logger.warn("Failed to create initial order status trail: #{e.message}")
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def save_order_status_trail(order, status_params)
|
|
446
|
+
create_order_status_observation(
|
|
447
|
+
order: order,
|
|
448
|
+
status: status_params['status'],
|
|
449
|
+
timestamp: status_params['status_time'] || Time.now,
|
|
450
|
+
updated_by: status_params['updated_by'] || {}
|
|
451
|
+
)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def save_order_status_trails_from_nlims(order, status_trail)
|
|
455
|
+
return unless status_trail.is_a?(Array)
|
|
456
|
+
|
|
457
|
+
status_trail.each do |trail_entry|
|
|
458
|
+
create_order_status_observation(
|
|
459
|
+
order: order,
|
|
460
|
+
status: trail_entry['status'],
|
|
461
|
+
timestamp: trail_entry['timestamp'],
|
|
462
|
+
updated_by: trail_entry['updated_by'] || {}
|
|
463
|
+
)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def save_test_status_trails_from_nlims(order, tests)
|
|
468
|
+
return unless tests.is_a?(Array)
|
|
469
|
+
|
|
470
|
+
tests.each do |test_data|
|
|
471
|
+
next unless test_data['status_trail'].is_a?(Array)
|
|
472
|
+
|
|
473
|
+
# Find the test by test_type
|
|
474
|
+
test_name = test_data.dig('test_type', 'name')
|
|
475
|
+
next unless test_name
|
|
476
|
+
|
|
477
|
+
# Find concept for this test
|
|
478
|
+
concept = Lab::Lims::Utils.find_concept_by_name(test_name)
|
|
479
|
+
next unless concept
|
|
480
|
+
|
|
481
|
+
# Find the test observation
|
|
482
|
+
test = order.tests.find_by(value_coded: concept.concept_id)
|
|
483
|
+
next unless test
|
|
484
|
+
|
|
485
|
+
# Save each status trail entry
|
|
486
|
+
test_data['status_trail'].each do |trail_entry|
|
|
487
|
+
create_test_status_observation(
|
|
488
|
+
test: test,
|
|
489
|
+
status: trail_entry['status'],
|
|
490
|
+
timestamp: trail_entry['timestamp'],
|
|
491
|
+
updated_by: trail_entry['updated_by'] || {}
|
|
492
|
+
)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Creates an observation for order status trail using obs table
|
|
498
|
+
def create_order_status_observation(order:, status:, timestamp:, updated_by: {})
|
|
499
|
+
# Find concept
|
|
500
|
+
order_status_concept = ConceptName.find_by(name: 'Lab Order Status')&.concept
|
|
501
|
+
|
|
502
|
+
unless order_status_concept
|
|
503
|
+
Rails.logger.warn('Missing Lab Order Status concept')
|
|
504
|
+
return
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Check if this exact status already exists
|
|
508
|
+
return if Observation.unscoped.exists?(
|
|
509
|
+
person_id: order.patient_id,
|
|
510
|
+
order_id: order.order_id,
|
|
511
|
+
concept_id: order_status_concept.concept_id,
|
|
512
|
+
value_text: status,
|
|
513
|
+
obs_datetime: timestamp,
|
|
514
|
+
voided: 0
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Create status observation
|
|
518
|
+
Observation.create!(
|
|
519
|
+
person_id: order.patient_id,
|
|
520
|
+
encounter_id: order.encounter_id,
|
|
521
|
+
order_id: order.order_id,
|
|
522
|
+
concept_id: order_status_concept.concept_id,
|
|
523
|
+
value_text: status, # Store status as text
|
|
524
|
+
obs_datetime: timestamp,
|
|
525
|
+
comments: updated_by.to_json,
|
|
526
|
+
creator: User.current&.user_id || 1,
|
|
527
|
+
date_created: Time.now,
|
|
528
|
+
uuid: SecureRandom.uuid
|
|
529
|
+
)
|
|
530
|
+
rescue StandardError => e
|
|
531
|
+
Rails.logger.error("Failed to create order status observation: #{e.message}")
|
|
532
|
+
Rails.logger.error(e.backtrace.first(5).join("\n"))
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Creates an observation for test status trail using obs table
|
|
536
|
+
def create_test_status_observation(test:, status:, timestamp:, updated_by: {})
|
|
537
|
+
# Find concept
|
|
538
|
+
test_status_concept = ConceptName.find_by(name: 'Lab Test Status')&.concept
|
|
539
|
+
|
|
540
|
+
unless test_status_concept
|
|
541
|
+
Rails.logger.warn('Missing Lab Test Status concept')
|
|
542
|
+
return
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Check if this exact status already exists
|
|
546
|
+
return if Observation.unscoped.exists?(
|
|
547
|
+
person_id: test.person_id,
|
|
548
|
+
obs_group_id: test.obs_id,
|
|
549
|
+
concept_id: test_status_concept.concept_id,
|
|
550
|
+
value_text: status,
|
|
551
|
+
obs_datetime: timestamp,
|
|
552
|
+
voided: 0
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Create status observation
|
|
556
|
+
Observation.create!(
|
|
557
|
+
person_id: test.person_id,
|
|
558
|
+
encounter_id: test.encounter_id,
|
|
559
|
+
obs_group_id: test.obs_id, # Link to parent test observation
|
|
560
|
+
concept_id: test_status_concept.concept_id,
|
|
561
|
+
value_text: status, # Store status as text
|
|
562
|
+
obs_datetime: timestamp,
|
|
563
|
+
comments: updated_by.to_json,
|
|
564
|
+
creator: User.current&.user_id || 1,
|
|
565
|
+
date_created: Time.now,
|
|
566
|
+
uuid: SecureRandom.uuid
|
|
567
|
+
)
|
|
568
|
+
rescue StandardError => e
|
|
569
|
+
Rails.logger.error("Failed to create test status observation: #{e.message}")
|
|
570
|
+
Rails.logger.error(e.backtrace.first(5).join("\n"))
|
|
571
|
+
end
|
|
372
572
|
end
|
|
373
573
|
end
|
|
374
574
|
end
|
|
@@ -14,8 +14,7 @@ module Lab
|
|
|
14
14
|
|
|
15
15
|
tests = filter_tests(tests, test_type_id: filters.delete(:test_type_id),
|
|
16
16
|
patient_id: filters.delete(:patient_id),
|
|
17
|
-
patient: filters.delete(:patient)
|
|
18
|
-
)
|
|
17
|
+
patient: filters.delete(:patient))
|
|
19
18
|
|
|
20
19
|
tests = filter_tests_by_results(tests) if %w[1 true].include?(filters[:pending_results]&.downcase)
|
|
21
20
|
|
|
@@ -29,12 +28,11 @@ module Lab
|
|
|
29
28
|
def create_tests(order, date, tests_params)
|
|
30
29
|
raise InvalidParameterError, 'tests are required' if tests_params.nil? || tests_params.empty?
|
|
31
30
|
|
|
32
|
-
|
|
33
31
|
Lab::LabTest.transaction do
|
|
34
32
|
tests_params.map do |params|
|
|
35
33
|
concept_id = params[:concept_id]
|
|
36
34
|
concept_id = Concept.find_concept_by_uuid(params[:concept]).id if concept_id.nil?
|
|
37
|
-
|
|
35
|
+
|
|
38
36
|
test = Lab::LabTest.create!(
|
|
39
37
|
concept_id: ConceptName.find_by_name!(Lab::Metadata::TEST_TYPE_CONCEPT_NAME)
|
|
40
38
|
.concept_id,
|
|
@@ -45,6 +43,9 @@ module Lab
|
|
|
45
43
|
value_coded: concept_id
|
|
46
44
|
)
|
|
47
45
|
|
|
46
|
+
# Create initial test status trail
|
|
47
|
+
create_initial_test_status_trail(test, date)
|
|
48
|
+
|
|
48
49
|
Lab::TestSerializer.serialize(test, order:)
|
|
49
50
|
end
|
|
50
51
|
end
|
|
@@ -100,6 +101,49 @@ module Lab
|
|
|
100
101
|
value_coded: test_type_id
|
|
101
102
|
)
|
|
102
103
|
end
|
|
104
|
+
|
|
105
|
+
def create_initial_test_status_trail(test, date)
|
|
106
|
+
# Find concept
|
|
107
|
+
test_status_concept = ConceptName.find_by(name: 'Lab Test Status')&.concept
|
|
108
|
+
|
|
109
|
+
unless test_status_concept
|
|
110
|
+
Rails.logger.warn('Missing Lab Test Status concept')
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
timestamp = date || test.obs_datetime || Time.now
|
|
115
|
+
|
|
116
|
+
# Check if 'Drawn' status already exists for this test
|
|
117
|
+
return if Observation.unscoped.exists?(
|
|
118
|
+
person_id: test.person_id,
|
|
119
|
+
obs_group_id: test.obs_id,
|
|
120
|
+
concept_id: test_status_concept.concept_id,
|
|
121
|
+
value_text: 'Drawn',
|
|
122
|
+
voided: 0
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Create status observation with 'Drawn' as initial status
|
|
126
|
+
Observation.create!(
|
|
127
|
+
person_id: test.person_id,
|
|
128
|
+
encounter_id: test.encounter_id,
|
|
129
|
+
obs_group_id: test.obs_id, # Link to parent test observation
|
|
130
|
+
concept_id: test_status_concept.concept_id,
|
|
131
|
+
value_text: 'Drawn', # Store status as text
|
|
132
|
+
obs_datetime: timestamp,
|
|
133
|
+
status: 'FINAL',
|
|
134
|
+
comments: {
|
|
135
|
+
'first_name' => User.current&.person&.names&.first&.given_name,
|
|
136
|
+
'last_name' => User.current&.person&.names&.first&.family_name,
|
|
137
|
+
'id' => User.current&.user_id&.to_s,
|
|
138
|
+
'phone_number' => nil
|
|
139
|
+
}.to_json,
|
|
140
|
+
creator: User.current&.user_id || 1,
|
|
141
|
+
date_created: Time.now,
|
|
142
|
+
uuid: SecureRandom.uuid
|
|
143
|
+
)
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
Rails.logger.warn("Failed to create initial test status trail: #{e.message}")
|
|
146
|
+
end
|
|
103
147
|
end
|
|
104
148
|
end
|
|
105
149
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This migration creates concepts for lab order and test status tracking using obs table
|
|
4
|
+
# Status values are stored as text (value_text), not coded values
|
|
5
|
+
class CreateLabStatusConcepts < ActiveRecord::Migration[5.2]
|
|
6
|
+
def up
|
|
7
|
+
ActiveRecord::Base.transaction do
|
|
8
|
+
# Find concept class and datatype
|
|
9
|
+
concept_class = ConceptClass.find_by(name: 'Finding') || ConceptClass.find_by(name: 'Misc')
|
|
10
|
+
text_datatype = ConceptDatatype.find_by(name: 'Text')
|
|
11
|
+
|
|
12
|
+
# Create Lab Order Status concept (stores status as value_text)
|
|
13
|
+
unless ConceptName.exists?(name: 'Lab Order Status')
|
|
14
|
+
order_status_concept = create_concept(
|
|
15
|
+
name: 'Lab Order Status',
|
|
16
|
+
class_id: concept_class.id,
|
|
17
|
+
datatype_id: text_datatype.id,
|
|
18
|
+
is_set: 0
|
|
19
|
+
)
|
|
20
|
+
puts "Created 'Lab Order Status' concept with ID: #{order_status_concept.concept_id}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Create Lab Test Status concept (stores status as value_text)
|
|
24
|
+
unless ConceptName.exists?(name: 'Lab Test Status')
|
|
25
|
+
test_status_concept = create_concept(
|
|
26
|
+
name: 'Lab Test Status',
|
|
27
|
+
class_id: concept_class.id,
|
|
28
|
+
datatype_id: text_datatype.id,
|
|
29
|
+
is_set: 0
|
|
30
|
+
)
|
|
31
|
+
puts "Created 'Lab Test Status' concept with ID: #{test_status_concept.concept_id}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
puts 'Lab status concepts created successfully'
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def down
|
|
39
|
+
ActiveRecord::Base.transaction do
|
|
40
|
+
# Remove concepts
|
|
41
|
+
concepts_to_remove = ['Lab Order Status', 'Lab Test Status']
|
|
42
|
+
|
|
43
|
+
concepts_to_remove.each do |name|
|
|
44
|
+
concept = ConceptName.find_by(name: name)&.concept
|
|
45
|
+
next unless concept
|
|
46
|
+
|
|
47
|
+
ConceptName.where(concept: concept).destroy_all
|
|
48
|
+
concept.destroy
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def create_concept(name:, class_id:, datatype_id:, is_set:)
|
|
56
|
+
concept = Concept.create!(
|
|
57
|
+
class_id: class_id,
|
|
58
|
+
datatype_id: datatype_id,
|
|
59
|
+
short_name: name,
|
|
60
|
+
retired: 0,
|
|
61
|
+
is_set: is_set,
|
|
62
|
+
creator: 1,
|
|
63
|
+
date_created: Time.current,
|
|
64
|
+
uuid: SecureRandom.uuid
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
ConceptName.create!(
|
|
68
|
+
concept: concept,
|
|
69
|
+
name: name,
|
|
70
|
+
locale: 'en',
|
|
71
|
+
locale_preferred: 1,
|
|
72
|
+
concept_name_type: 'FULLY_SPECIFIED',
|
|
73
|
+
creator: 1,
|
|
74
|
+
date_created: Time.current,
|
|
75
|
+
uuid: SecureRandom.uuid
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
concept
|
|
79
|
+
end
|
|
80
|
+
end
|
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.1.
|
|
4
|
+
version: 2.1.9.pre.alpha
|
|
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-
|
|
11
|
+
date: 2026-03-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: couchrest
|
|
@@ -302,6 +302,7 @@ files:
|
|
|
302
302
|
- db/migrate/20260119104240_add_fulfiller_fields_to_orders.rb
|
|
303
303
|
- db/migrate/20260119104241_create_comment_to_fulfiller_concept.rb
|
|
304
304
|
- db/migrate/20260128111557_add_program_id_to_encounter.rb
|
|
305
|
+
- db/migrate/20260226065149_create_lab_status_concepts.rb
|
|
305
306
|
- lib/auto12epl.rb
|
|
306
307
|
- lib/couch_bum/couch_bum.rb
|
|
307
308
|
- lib/generators/lab/install/USAGE
|
|
@@ -340,9 +341,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
340
341
|
version: '0'
|
|
341
342
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
342
343
|
requirements:
|
|
343
|
-
- - "
|
|
344
|
+
- - ">"
|
|
344
345
|
- !ruby/object:Gem::Version
|
|
345
|
-
version:
|
|
346
|
+
version: 1.3.1
|
|
346
347
|
requirements: []
|
|
347
348
|
rubygems_version: 3.4.1
|
|
348
349
|
signing_key:
|