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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44e72036503453b83d8f3cfcdff281e1085e461c476a8f793e24a3d72f12f223
4
- data.tar.gz: 0100d95046a1a81db0fc8b7eace3e9fa02dc2f9be6f396f073954bbfa5578993
3
+ metadata.gz: cfef9418fc9d94d233f24d04883623d67e0c59c2aacea66bc15d3cca61192f78
4
+ data.tar.gz: 2d6324bf11f30c87398819042e3a0835275596961bfd838d088a63ae7a4f93c2
5
5
  SHA512:
6
- metadata.gz: 25cb61dabafa209680d649747e35a06e19ed3685c7730bc0c539a35357e9e9529794ed180714c626125ec6ab8104b107e90e6ad0ab0cbebfcaee2ae728ea7815
7
- data.tar.gz: 0da64fc5aa9647ffcc3252ae597b0492bacb3b77ec97064c4433f72bcd122431e04114323f7f13dc602faa0ccdc7c2f836b098a1090e9a56fa8cae774503101f
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
@@ -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
- Lab::Metadata::ORDER_TYPE_NAME,
51
- Lab::Metadata::HTS_ORDER_TYPE_NAME
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
- includes(:reason_for_test,
61
- :requesting_clinician,
62
- :target_lab,
63
- :comment_to_fulfiller,
64
- tests: [:result])
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
@@ -3,9 +3,9 @@
3
3
  module Lab
4
4
  class LabResult < Observation
5
5
  def children
6
- Observation.where(obs_group_id: obs_id, voided: 0)
6
+ Observation.unscoped.where(obs_group_id: obs_id, voided: 0)
7
7
  end
8
-
8
+
9
9
  alias measures children
10
10
 
11
11
  default_scope do
@@ -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(concept: ConceptName.where(name: Lab::Metadata::TEST_RESULT_CONCEPT_NAME)) },
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(reason)
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.children.first
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: measures, concept_id: test)
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(:concept_set).push(0).join(',')})
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
- if order_dto['priority'].nil? || order_dto['sample_type'].casecmp?('not_specified')
81
- patch_order_dto_with_lims_order!(order_dto, find_lims_order(order.accession_number))
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 # LIMS responds with a 401 when a result is not found :(
87
- Rails.logger.error("Failed to fetch results for ##{order.accession_number}: #{e.message}")
88
- next
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, force_update: params.fetch('force_update', false))
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], tests: [:result])
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
- order = find_order(order_params['tracking_number'])
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
- patch_order_dto_with_lims_results!(order_dto, order_params['results'])
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
- data[:lab_orders] = orders.map do |order|
164
- Lab::LabOrderSerializer.serialize_order(
165
- order, requesting_clinician: order.requesting_clinician,
166
- reason_for_test: order.reason_for_test,
167
- target_lab: order.target_lab
168
- )
169
- end if include_data
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 = { 'type': 'LIMS',
178
- 'specimen': ConceptName.find_by(concept_id: order.concept_id)&.name,
179
- 'accession_number': order&.accession_number,
180
- 'order_date': order&.start_date,
181
- 'arv_number': find_arv_number(order.patient_id),
182
- 'patient_id': result.person_id,
183
- 'ordered_by': order&.provider&.person&.name,
184
- 'rejection_reason': order_params['comments'] }.as_json
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
- encounter.program_id = order_params[:program_id] if Encounter.column_names.include?('program_id') && order_params[:program_id].present?
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
- order.comment_to_fulfiller = params[:comment_to_fulfiller] if params[:comment_to_fulfiller]
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').concept_id
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
- raise InvalidParameterError, "Can't change reason for test once set" if order.reason_for_test&.value_coded && !force_update
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lab
4
- VERSION = '2.1.8'
4
+ VERSION = '2.1.9-alpha'
5
5
  end
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.8
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-02-27 00:00:00.000000000 Z
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: '0'
346
+ version: 1.3.1
346
347
  requirements: []
347
348
  rubygems_version: 3.4.1
348
349
  signing_key: