his_emr_api_lab 2.1.8.7 → 2.1.9.pre.alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,7 @@ 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
- # Note: comment_to_fulfiller is a has_one association, not a field
315
+ # NOTE: comment_to_fulfiller is a has_one association, not a field
264
316
  # It will be created via add_comment_to_fulfiller method
265
317
  order.accession_number = access_number
266
318
  order.orderer = User.current&.user_id
@@ -349,7 +401,7 @@ module Lab
349
401
  end
350
402
 
351
403
  def unknown_concept_id
352
- ConceptName.find_by_name!('Unknown').concept_id
404
+ ConceptName.find_by_name!('Unknown').concept
353
405
  end
354
406
 
355
407
  def update_reason_for_test(order, concept_id, force_update: false)
@@ -357,7 +409,10 @@ module Lab
357
409
 
358
410
  return if order.reason_for_test&.value_coded == concept_id
359
411
 
360
- 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
361
416
 
362
417
  order.reason_for_test&.delete
363
418
  date = order.start_date if order.respond_to?(:start_date)
@@ -370,6 +425,150 @@ module Lab
370
425
  obs.void('New Status Received from LIMS')
371
426
  end
372
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
373
572
  end
374
573
  end
375
574
  end
@@ -43,11 +43,7 @@ module Lab
43
43
  ActiveRecord::Base.connection.commit_db_transaction
44
44
 
45
45
  # Execute job synchronously
46
- begin
47
- ProcessLabResultJob.perform_now(results_obs.id, serializer, result_enter_by)
48
- rescue StandardError => e
49
- Rails.logger.error("Lab::ResultsService: Error processing lab result job for test #{test_id}: #{e.message}")
50
- end
46
+ ProcessLabResultJob.perform_now(results_obs.id, serializer, result_enter_by)
51
47
 
52
48
  Rails.logger.info("Lab::ResultsService: Result created for test #{test_id} #{serializer}")
53
49
  serializer
@@ -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.7'
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.7
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-04-10 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: