his_emr_api_lab 2.4.1 → 2.4.2.pre.beta

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: 991f9ab4aaf486c50137bd472f03d80b8ab12d09a01a020fa0d5f2cd469bc771
4
- data.tar.gz: 617e5482ada6f17c4daaef81e884b5496f04fb39b8fa0cb60be2b66aaf391612
3
+ metadata.gz: 4c709c42cf59eed483429321c58ddf08678dc809850c2d59cb58ca8d8bf4f3c3
4
+ data.tar.gz: 3f54225c34b8547665dfa5d553971e7d0b299d6744496d9e457184f3c7c105b8
5
5
  SHA512:
6
- metadata.gz: 9a8ffca6167d119463b70f17b76576aa843b7015f0fc9ae8d4f252f0a249089c07e4acb731e7d7ac657235bb7ff8f4674cbe3a99888f863eff6ad7d7764ad537
7
- data.tar.gz: 41d6799ca76f3727c87f70836417564a1459a7f327d5576ffe115d696bd111f04f756d702ed00122d16b40e26d4bb070fc66d8bf4eefdc35aff69625f1633e30
6
+ metadata.gz: c53d18764e2dd4d99e4881855778acec6c8a2852744eb8bba144aea85249d8a0d5555f6c6e85773333367439606fa8652104d14169cb0290964bd308c23f5e7a
7
+ data.tar.gz: 45a1847d5591bbd4e352d15d320651cbaa2b2d774b5752cc17caceb8416a3fa56c505efd230b3c4635bb334c4d2bd3d2e885de5996324e94da1f5a82d01260c6
@@ -16,8 +16,9 @@ module Lab
16
16
 
17
17
  belongs_to :test,
18
18
  (lambda do
19
- where(concept: ConceptName.where(name: Lab::Metadata::TEST_TYPE_CONCEPT_NAME)
20
- .select(:concept_id))
19
+ unscope(where: :location_id)
20
+ .where(concept: ConceptName.where(name: Lab::Metadata::TEST_TYPE_CONCEPT_NAME)
21
+ .select(:concept_id))
21
22
  end),
22
23
  class_name: 'Observation',
23
24
  foreign_key: :obs_group_id
@@ -1,15 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../../services/lab/order_location_resolver'
4
+
3
5
  module Lab
4
6
  module LabOrderSerializer
5
7
  def self.serialize_order(order, tests: nil, requesting_clinician: nil, reason_for_test: nil, target_lab: nil, comment_to_fulfiller: nil)
6
- tests ||= order.voided == 1 ? voided_tests(order) : order.tests
7
- requesting_clinician ||= order.requesting_clinician
8
- comment_to_fulfiller ||= order.comment_to_fulfiller
9
- reason_for_test ||= order.reason_for_test
10
- target_lab = target_lab&.value_text || order.target_lab&.value_text || Location.current_health_center&.name
11
-
12
- encounter = Encounter.find_by_encounter_id(order.encounter_id)
8
+ tests ||= [1, true].include?(order.voided) ? voided_tests(order) : order_tests(order)
9
+ requesting_clinician ||= order_observation(order, Lab::Metadata::REQUESTING_CLINICIAN_CONCEPT_NAME)
10
+ comment_to_fulfiller ||= order_observation(order, Lab::Metadata::COMMENT_TO_FULFILLER_CONCEPT_NAME)
11
+ reason_for_test ||= order_observation(order, Lab::Metadata::REASON_FOR_TEST_CONCEPT_NAME)
12
+
13
+ encounter = Encounter.unscoped.find_by_encounter_id(order.encounter_id)
14
+ location = Lab::OrderLocationResolver.location_for_order(order, fallback_location_id: encounter&.location_id)
15
+ target_lab = target_lab&.value_text ||
16
+ order_observation(order, Lab::Metadata::TARGET_LAB_CONCEPT_NAME)&.value_text ||
17
+ location&.name ||
18
+ Location.current_health_center&.name
13
19
  program = Program.find_by_program_id(encounter&.program_id)
14
20
 
15
21
  ActiveSupport::HashWithIndifferentAccess.new(
@@ -20,7 +26,7 @@ module Lab
20
26
  encounter_id: order.encounter_id,
21
27
  **(Encounter.column_names.include?('visit_id') ? { visit_id: encounter&.visit_id } : {}),
22
28
  order_date: order.start_date,
23
- location_id: encounter&.location_id,
29
+ location_id: location&.location_id,
24
30
  program_id: encounter&.program_id,
25
31
  program_name: program&.name,
26
32
  patient_id: order.patient_id,
@@ -59,8 +65,10 @@ module Lab
59
65
 
60
66
  def self.test_method(order, _concept_id)
61
67
  obs = ::Observation
68
+ .unscoped
62
69
  .select(:value_coded)
63
- .where(concept_id: ConceptName.find_by_name(Metadata::TEST_METHOD_CONCEPT_NAME).concept_id, order_id: order.id)
70
+ .where(concept_id: ConceptName.find_by_name(Metadata::TEST_METHOD_CONCEPT_NAME).concept_id, order_id: order.order_id)
71
+ .where(voided: 0)
64
72
  .first
65
73
  {
66
74
  concept_id: obs&.value_coded,
@@ -75,10 +83,25 @@ module Lab
75
83
  ::ConceptName.find_by_concept_id(concept_id)&.name
76
84
  end
77
85
 
86
+ def self.order_tests(order)
87
+ concept = ConceptName.where(name: Lab::Metadata::TEST_TYPE_CONCEPT_NAME)
88
+ .select(:concept_id)
89
+ LabTest.unscoped.where(concept_id: concept, order_id: order.order_id, voided: 0)
90
+ .order(:date_created, :obs_id)
91
+ end
92
+
78
93
  def self.voided_tests(order)
79
94
  concept = ConceptName.where(name: Lab::Metadata::TEST_TYPE_CONCEPT_NAME)
80
95
  .select(:concept_id)
81
- LabTest.unscoped.where(concept:, order:, voided: true)
96
+ LabTest.unscoped.where(concept_id: concept, order_id: order.order_id, voided: 1)
97
+ .order(:date_voided, :obs_id)
98
+ end
99
+
100
+ def self.order_observation(order, concept_name)
101
+ concept = ConceptName.where(name: concept_name).select(:concept_id)
102
+ Observation.unscoped.where(order_id: order.order_id, concept_id: concept, voided: 0)
103
+ .order(:date_created, :obs_id)
104
+ .first
82
105
  end
83
106
 
84
107
  def self.latest_order_status(order)
@@ -11,8 +11,8 @@ module Lab
11
11
  concept_name = get_test_catalog_concept_name(measure.concept_id)
12
12
  program_id = ''
13
13
  if measure.obs_id.present?
14
- obs = Observation.unscope(where: :obs_group_id).find_by(obs_id: measure.obs_id)
15
- encounter = Encounter.find_by(encounter_id: obs&.encounter_id)
14
+ obs = Observation.unscoped.find_by(obs_id: measure.obs_id)
15
+ encounter = Encounter.unscoped.find_by(encounter_id: obs&.encounter_id)
16
16
  program_id = encounter&.program_id
17
17
  end
18
18
 
@@ -19,11 +19,12 @@ module Lab
19
19
  def create_order(order_dto)
20
20
  response = in_authenticated_session do |headers|
21
21
  Rails.logger.info("Pushing order ##{order_dto[:tracking_number]} to LIMS")
22
+ payload = make_create_params(order_dto)
22
23
  if order_dto['sample_type'].casecmp?('not_specified')
23
- RestClient.post(expand_uri('orders/requests', api_version: 'v2'), make_create_params(order_dto),
24
- headers)
24
+ RestClient.post(expand_uri('orders/requests', api_version: 'v2'), payload.to_json,
25
+ json_headers(headers))
25
26
  else
26
- RestClient.post(expand_uri('orders', api_version: 'v2'), make_create_params(order_dto), headers)
27
+ RestClient.post(expand_uri('orders', api_version: 'v2'), payload.to_json, json_headers(headers))
27
28
  end
28
29
  end
29
30
 
@@ -54,7 +55,7 @@ module Lab
54
55
  date_acknowledged: acknowledgement_dto[:date_acknowledged],
55
56
  recipient_type: acknowledgement_dto[:recipient_type],
56
57
  acknowledged_by: 'emr_at_facility'
57
- }, headers)
58
+ }.to_json, json_headers(headers))
58
59
  end
59
60
  Rails.logger.info("Acknowledged order ##{acknowledgement_dto} in LIMS. Response: #{response}")
60
61
  JSON.parse(response)
@@ -66,7 +67,7 @@ module Lab
66
67
  def update_order(_id, order_dto)
67
68
  in_authenticated_session do |headers|
68
69
  RestClient.put(expand_uri("orders/#{order_dto[:tracking_number]}", api_version: 'v2'),
69
- make_update_params(order_dto), headers)
70
+ make_update_params(order_dto).to_json, json_headers(headers))
70
71
  end
71
72
 
72
73
  update_order_results(order_dto)
@@ -176,7 +177,8 @@ module Lab
176
177
  in_authenticated_session do |headers|
177
178
  date_voided, voided_status = find_test_status(order_dto, test.name, 'Voided')
178
179
  params = make_void_test_params(tracking_number, test, voided_status['updated_by'], date_voided)
179
- RestClient.put(expand_uri("tests/#{tracking_number}", api_version: 'v2'), params.to_json, headers)
180
+ RestClient.put(expand_uri("tests/#{tracking_number}", api_version: 'v2'), params.to_json,
181
+ json_headers(headers))
180
182
  end
181
183
  end
182
184
  end
@@ -277,6 +279,10 @@ module Lab
277
279
  raise InvalidParameters, body['message']
278
280
  end
279
281
 
282
+ def json_headers(headers)
283
+ headers.merge('Content-Type' => 'application/json', 'Accept' => 'application/json')
284
+ end
285
+
280
286
  ##
281
287
  # Takes a LIMS API relative URI and converts it to a full URL.
282
288
  def expand_uri(uri, api_version: 'v1')
@@ -328,18 +334,33 @@ module Lab
328
334
  date_of_birth: order_dto.fetch(:patient).fetch(:dob)
329
335
  },
330
336
  tests: order_dto.fetch(:tests_map).map do |test|
331
- concept = ::Concept.find(test.concept_id)
337
+ concept = test_type_concept(test)
332
338
  {
333
339
  test_type: {
334
340
  name: concept.test_catalogue_name,
335
341
  nlims_code: concept.nlims_code,
336
342
  method_of_testing: test&.test_method&.name
337
- }
343
+ }.compact
338
344
  }
339
345
  end
340
346
  }
341
347
  end
342
348
 
349
+ def test_type_concept(test)
350
+ concept = ::Concept.find(test.concept_id)
351
+ return concept if concept.nlims_code.to_s.match?(/\ANLIMS_TT_/)
352
+
353
+ parent_test_types = ConceptSet.where(concept_id: concept.concept_id).filter_map do |concept_set|
354
+ parent = ::Concept.find_by(concept_id: concept_set.concept_set)
355
+ parent if parent&.nlims_code.to_s.match?(/\ANLIMS_TT_/)
356
+ end.uniq(&:concept_id)
357
+
358
+ return parent_test_types.first if parent_test_types.one?
359
+
360
+ Rails.logger.warn("Could not resolve test type for concept ##{concept.concept_id} (#{concept.test_catalogue_name})")
361
+ concept
362
+ end
363
+
343
364
  ##
344
365
  # Converts an OrderDto to parameters for POST /update_order
345
366
  def make_update_params(order_dto)
@@ -506,7 +527,8 @@ module Lab
506
527
  in_authenticated_session do |headers|
507
528
  params = make_update_test_params(order_dto, test_name, results)
508
529
 
509
- RestClient.put(expand_uri("tests/#{order_dto['tracking_number']}", api_version: 'v2'), params, headers)
530
+ RestClient.put(expand_uri("tests/#{order_dto['tracking_number']}", api_version: 'v2'), params.to_json,
531
+ json_headers(headers))
510
532
  end
511
533
  end
512
534
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'exceptions'
4
+ require_relative '../order_location_resolver'
4
5
 
5
6
  module Lab
6
7
  module Lims
@@ -12,10 +13,13 @@ module Lab
12
13
  ##
13
14
  # Unpacks a LIMS order into an object that OrdersService can handle
14
15
  def to_order_service_params(patient_id:)
16
+ location = Lab::OrderLocationResolver.location_from_name(facility_name(self['sending_facility'])) ||
17
+ Lab::OrderLocationResolver.location_from_name(self['order_location'])
15
18
  ActiveSupport::HashWithIndifferentAccess.new(
16
19
  program_id: lab_program.program_id,
17
20
  accession_number: self['tracking_number'],
18
21
  patient_id:,
22
+ location_id: location&.location_id,
19
23
  specimen: { concept_id: specimen_type_id },
20
24
  tests: self['tests_map']&.map { |test| { concept_id: test.concept_id } },
21
25
  requesting_clinician:,
@@ -3,6 +3,7 @@
3
3
  require_relative 'config'
4
4
  require_relative 'order_dto'
5
5
  require_relative 'utils'
6
+ require_relative '../order_location_resolver'
6
7
 
7
8
  module Lab
8
9
  module Lims
@@ -46,21 +47,7 @@ module Lab
46
47
  private
47
48
 
48
49
  def get_location(location_id, order)
49
- # Try to get location from the location_id first
50
- location = Location.find_by(location_id: location_id) if location_id.present?
51
-
52
- # Fallback to current health center
53
- location ||= Location.current_health_center
54
-
55
- # Last fallback: try to get from order's observation encounter
56
- # Use unscoped to find observations/encounters across all locations
57
- if location.nil? && order.present?
58
- obs = Observation.unscoped.find_by(order_id: order.order_id)
59
- if obs.respond_to?(:encounter) && obs.encounter.respond_to?(:location)
60
- encounter = Encounter.unscoped.find_by(encounter_id: obs.encounter_id)
61
- location = encounter&.location
62
- end
63
- end
50
+ location = Lab::OrderLocationResolver.location_for_order(order, fallback_location_id: location_id)
64
51
 
65
52
  raise 'Current health center not set' unless location
66
53
 
@@ -161,7 +148,7 @@ module Lab
161
148
  end
162
149
 
163
150
  def format_test_status_trail(order)
164
- tests = [0, false].include?(order.voided) ? order.tests : Lab::LabOrderSerializer.voided_tests(order)
151
+ tests = [0, false].include?(order.voided) ? Lab::LabOrderSerializer.order_tests(order) : Lab::LabOrderSerializer.voided_tests(order)
165
152
  tests.each_with_object({}) do |test, trail|
166
153
  test_name = format_test_name(::Concept.find(test.value_coded).test_catalogue_name)
167
154
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../order_location_resolver'
4
+
3
5
  module Lab
4
6
  module Lims
5
7
  ##
@@ -191,7 +193,9 @@ module Lab
191
193
 
192
194
  def create_order(patient, order_dto)
193
195
  logger.debug("Creating order ##{order_dto['_id']}")
194
- order = OrdersService.order_test(order_dto.to_order_service_params(patient_id: patient.patient_id))
196
+ params = order_dto.to_order_service_params(patient_id: patient.patient_id)
197
+ params[:location_id] ||= location_id_for_order_dto(order_dto)
198
+ order = OrdersService.order_test(params)
195
199
 
196
200
  # Extract and save status trails from NLIMS
197
201
  save_status_trails_from_nlims(order, order_dto)
@@ -204,8 +208,9 @@ module Lab
204
208
 
205
209
  def update_order(patient, order_id, order_dto)
206
210
  logger.debug("Updating order ##{order_dto['_id']}")
207
- order = OrdersService.update_order(order_id, order_dto.to_order_service_params(patient_id: patient.patient_id)
208
- .merge(force_update: 'true'))
211
+ params = order_dto.to_order_service_params(patient_id: patient.patient_id)
212
+ params[:location_id] ||= Lab::OrderLocationResolver.location_id_for_order_id(order_id, facility_name: order_dto[:sending_facility])
213
+ order = OrdersService.update_order(order_id, params.merge(force_update: 'true'))
209
214
 
210
215
  # Extract and save status trails from NLIMS
211
216
  save_status_trails_from_nlims(order, order_dto)
@@ -245,6 +250,7 @@ module Lab
245
250
  ResultsService.create_results(test.id, { provider_id: User.current.person_id,
246
251
  date: Utils.parse_date(test_results['result_date'] || result_date,
247
252
  order[:order_date].to_s),
253
+ location_id: Lab::OrderLocationResolver.location_id_for_test(test),
248
254
  comments: "LIMS import: Entered by: #{creator}",
249
255
  measures: })
250
256
  end
@@ -255,7 +261,13 @@ module Lab
255
261
  test_concept = Utils.find_concept_by_name(test_name)
256
262
  raise "Unknown test name, #{test_name}!" unless test_concept
257
263
 
258
- LabTest.find_by(order_id:, value_coded: test_concept.concept_id)
264
+ LabTest.unscoped.find_by(order_id:, value_coded: test_concept.concept_id, voided: 0)
265
+ end
266
+
267
+ def location_id_for_order_dto(order_dto)
268
+ local_order = Lab::LabOrder.unscoped.find_by(accession_number: order_dto[:tracking_number])
269
+
270
+ Lab::OrderLocationResolver.location_id_for_order(local_order, facility_name: order_dto[:sending_facility])
259
271
  end
260
272
 
261
273
  def find_measure(_order, indicator_name, value)
@@ -342,6 +354,7 @@ module Lab
342
354
  logger.error("Order not found: #{order_id}")
343
355
  return
344
356
  end
357
+ location_id = Lab::OrderLocationResolver.location_id_for_order(lab_order)
345
358
 
346
359
  # sample_statuses is an array of single-key hashes like:
347
360
  # [{ "20260225120000" => { "status" => "Drawn", ... } }, { "20260225130000" => { ... } }]
@@ -383,6 +396,7 @@ module Lab
383
396
  obs_datetime: timestamp,
384
397
  comments: updated_by.to_json,
385
398
  creator: User.current&.user_id || 1,
399
+ location_id:,
386
400
  date_created: Time.now,
387
401
  uuid: SecureRandom.uuid
388
402
  )
@@ -412,8 +426,9 @@ module Lab
412
426
  test_concept = Utils.find_concept_by_name(Utils.translate_test_name(test_name))
413
427
  next unless test_concept
414
428
 
415
- test = Lab::LabTest.find_by(order_id: order['order_id'], value_coded: test_concept.concept_id)
429
+ test = Lab::LabTest.unscoped.find_by(order_id: order['order_id'], value_coded: test_concept.concept_id, voided: 0)
416
430
  next unless test
431
+ location_id = Lab::OrderLocationResolver.location_id_for_test(test)
417
432
 
418
433
  # Process each status in the trail
419
434
  statuses.each do |timestamp_key, status_data|
@@ -449,6 +464,7 @@ module Lab
449
464
  obs_datetime: timestamp,
450
465
  comments: updated_by.to_json,
451
466
  creator: User.current&.user_id || 1,
467
+ location_id:,
452
468
  date_created: Time.now,
453
469
  uuid: SecureRandom.uuid
454
470
  )
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'metadata'
4
+
5
+ module Lab
6
+ # Resolves the location that should be used for lab sync records.
7
+ #
8
+ # LIMS workers often run as lab_daemon, whose current location can differ from
9
+ # the clinician/order location. Order-linked observations are the most reliable
10
+ # source because they carry the location where the order was created.
11
+ module OrderLocationResolver
12
+ ORDER_OBSERVATION_CONCEPT_NAMES = [
13
+ Lab::Metadata::TEST_TYPE_CONCEPT_NAME,
14
+ Lab::Metadata::TEST_METHOD_CONCEPT_NAME,
15
+ Lab::Metadata::REQUESTING_CLINICIAN_CONCEPT_NAME,
16
+ Lab::Metadata::REASON_FOR_TEST_CONCEPT_NAME,
17
+ Lab::Metadata::TARGET_LAB_CONCEPT_NAME,
18
+ Lab::Metadata::COMMENT_TO_FULFILLER_CONCEPT_NAME
19
+ ].freeze
20
+
21
+ module_function
22
+
23
+ def location_for_order(order, fallback_location_id: nil, facility_name: nil)
24
+ observation_location_for_order(order) ||
25
+ location_by_id(fallback_location_id || record_location_id(order)) ||
26
+ encounter_location_for_order(order) ||
27
+ creator_location_for_order(order) ||
28
+ location_from_name(facility_name) ||
29
+ Location.current_health_center
30
+ end
31
+
32
+ def location_id_for_order(order, fallback_location_id: nil, facility_name: nil)
33
+ location_for_order(order, fallback_location_id:, facility_name:)&.location_id
34
+ end
35
+
36
+ def location_for_order_id(order_id, fallback_location_id: nil, facility_name: nil)
37
+ order = Lab::LabOrder.unscoped.find_by(order_id:)
38
+
39
+ location_for_order(order, fallback_location_id:, facility_name:)
40
+ end
41
+
42
+ def location_id_for_order_id(order_id, fallback_location_id: nil, facility_name: nil)
43
+ location_for_order_id(order_id, fallback_location_id:, facility_name:)&.location_id
44
+ end
45
+
46
+ def location_for_test(test)
47
+ location_for_order_id(record_order_id(test), fallback_location_id: record_location_id(test)) ||
48
+ location_by_id(record_location_id(test)) ||
49
+ location_by_id(encounter_location_id_for_record(test))
50
+ end
51
+
52
+ def location_id_for_test(test)
53
+ location_for_test(test)&.location_id
54
+ end
55
+
56
+ def location_from_name(name)
57
+ name = name.to_s.strip
58
+ return nil if name.blank? || name.casecmp?('Unknown') || name.casecmp?('not_assigned')
59
+
60
+ Location.unscoped.where('LOWER(name) = ?', name.downcase).first
61
+ end
62
+
63
+ def location_by_id(location_id)
64
+ return nil if location_id.blank? || location_id.to_i.zero?
65
+
66
+ Location.unscoped.find_by(location_id:)
67
+ end
68
+
69
+ def observation_location_for_order(order)
70
+ order_id = record_order_id(order)
71
+ return nil if order_id.blank?
72
+
73
+ location_id = prioritized_order_observations(order_id).first&.location_id
74
+ location_by_id(location_id)
75
+ end
76
+
77
+ def encounter_location_for_order(order)
78
+ location_by_id(encounter_location_id_for_record(order))
79
+ end
80
+
81
+ def creator_location_for_order(order)
82
+ creator_id = record_creator_id(order)
83
+ return nil if creator_id.blank?
84
+
85
+ user = User.unscoped.find_by(user_id: creator_id)
86
+ location_by_id(user&.location_id)
87
+ end
88
+
89
+ def prioritized_order_observations(order_id)
90
+ scope = Observation.unscoped.where(order_id:, voided: 0).where.not(location_id: [nil, 0])
91
+ concept_ids = order_location_concept_ids
92
+ return scope.order(:date_created, :obs_id) if concept_ids.blank?
93
+
94
+ quoted_ids = concept_ids.map(&:to_i).join(',')
95
+ scope.order(Arel.sql("CASE WHEN concept_id IN (#{quoted_ids}) THEN 0 ELSE 1 END, date_created ASC, obs_id ASC"))
96
+ end
97
+
98
+ def order_location_concept_ids
99
+ ConceptName.where(name: ORDER_OBSERVATION_CONCEPT_NAMES).pluck(:concept_id)
100
+ end
101
+
102
+ def encounter_location_id_for_record(record)
103
+ encounter_id = record_encounter_id(record)
104
+ return nil if encounter_id.blank?
105
+
106
+ Encounter.unscoped.find_by(encounter_id:)&.location_id
107
+ end
108
+
109
+ def record_order_id(record)
110
+ return nil unless record
111
+ return record.order_id if record.respond_to?(:order_id)
112
+
113
+ value_from_hash(record, :order_id) || value_from_hash(record, :id)
114
+ end
115
+
116
+ def record_encounter_id(record)
117
+ return nil unless record
118
+ return record.encounter_id if record.respond_to?(:encounter_id)
119
+
120
+ value_from_hash(record, :encounter_id)
121
+ end
122
+
123
+ def record_location_id(record)
124
+ return nil unless record
125
+ return record.location_id if record.respond_to?(:location_id)
126
+
127
+ value_from_hash(record, :location_id)
128
+ end
129
+
130
+ def record_creator_id(record)
131
+ return nil unless record
132
+ return record.creator if record.respond_to?(:creator)
133
+
134
+ value_from_hash(record, :creator)
135
+ end
136
+
137
+ def value_from_hash(record, key)
138
+ return nil unless record.respond_to?(:[])
139
+
140
+ record[key] || record[key.to_s]
141
+ end
142
+ end
143
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'order_location_resolver'
4
+
3
5
  module Lab
4
6
  ##
5
7
  # Manage lab orders.
@@ -163,6 +165,7 @@ module Lab
163
165
  # find the order
164
166
  order = find_order(order_params['tracking_number'])
165
167
  concept = ConceptName.find_by_name Lab::Metadata::LAB_ORDER_STATUS_CONCEPT_NAME
168
+ location_id = Lab::OrderLocationResolver.location_id_for_order(order)
166
169
  ActiveRecord::Base.transaction do
167
170
  void_order_status(order, concept)
168
171
  Observation.create!(
@@ -172,7 +175,8 @@ module Lab
172
175
  order_id: order.id,
173
176
  obs_datetime: order_params['status_time'] || Time.now,
174
177
  value_text: order_params['status'],
175
- creator: User.current.id
178
+ creator: User.current.id,
179
+ location_id:
176
180
  )
177
181
 
178
182
  # Save order status trail if available
@@ -312,9 +316,14 @@ module Lab
312
316
  encounter_id = order_params[:encounter_id] || order_params[:encounter]
313
317
  patient_id = order_params[:patient_id] || order_params[:patient]
314
318
  visit = order_params[:visit]
319
+ location_id = location_id_from_order_params(order_params)
315
320
 
316
- return Encounter.find(encounter_id) if order_params[:encounter] || order_params[:encounter_id]
317
- raise StandardError, 'encounter_id|uuid or patient_id|uuid required' unless order_params[:patient]
321
+ if order_params[:encounter] || order_params[:encounter_id]
322
+ encounter = Encounter.unscoped.find(encounter_id)
323
+ encounter.update!(location_id:) if location_id.present? && encounter.respond_to?(:location_id) && encounter.location_id.blank?
324
+ return encounter
325
+ end
326
+ raise StandardError, 'encounter_id|uuid or patient_id|uuid required' unless patient_id
318
327
 
319
328
  encounter = Encounter.new
320
329
  encounter.patient = Patient.find(patient_id)
@@ -325,6 +334,7 @@ module Lab
325
334
  if Encounter.column_names.include?('program_id') && order_params[:program_id].present?
326
335
  encounter.program_id = order_params[:program_id]
327
336
  end
337
+ encounter.location_id = location_id if Encounter.column_names.include?('location_id') && location_id.present?
328
338
  encounter.save!
329
339
  encounter.reload
330
340
  end
@@ -433,6 +443,7 @@ module Lab
433
443
  # Use unscoped to find user regardless of location context
434
444
  creator = User.unscoped.find_by(username: 'lab_daemon')
435
445
  User.current ||= creator
446
+ values[:location_id] ||= Lab::OrderLocationResolver.location_id_for_order(order)
436
447
  Observation.create!(
437
448
  order:,
438
449
  encounter_id: order.encounter_id,
@@ -454,25 +465,42 @@ module Lab
454
465
  def update_reason_for_test(order, concept_id, force_update: false)
455
466
  raise InvalidParameterError, "Reason for test can't be blank" if concept_id.blank?
456
467
 
457
- return if order.reason_for_test&.value_coded == concept_id
468
+ current_reason_for_test = order_observation(order, Lab::Metadata::REASON_FOR_TEST_CONCEPT_NAME)
469
+ return if current_reason_for_test&.value_coded == concept_id
458
470
 
459
- if order.reason_for_test&.value_coded && !force_update
471
+ if current_reason_for_test&.value_coded && !force_update
460
472
  raise InvalidParameterError,
461
473
  "Can't change reason for test once set"
462
474
  end
463
475
 
464
- order.reason_for_test&.delete
476
+ current_reason_for_test&.delete
465
477
  date = order.start_date if order.respond_to?(:start_date)
466
478
  date ||= order.date_created
467
479
  add_reason_for_test(order, date: date, reason_for_test_id: concept_id)
468
480
  end
469
481
 
470
482
  def void_order_status(order, concept)
471
- Observation.where(order_id: order.id, concept_id: concept.concept_id).each do |obs|
483
+ Observation.unscoped.where(order_id: order.id, concept_id: concept.concept_id).each do |obs|
472
484
  obs.void('New Status Received from LIMS')
473
485
  end
474
486
  end
475
487
 
488
+ def location_id_from_order_params(order_params)
489
+ order_params[:location_id] ||
490
+ order_params[:order_location_id] ||
491
+ Lab::OrderLocationResolver.location_from_name(order_params[:order_location])&.location_id ||
492
+ Lab::OrderLocationResolver.location_from_name(order_params['order_location'])&.location_id ||
493
+ Lab::OrderLocationResolver.location_from_name(order_params[:target_lab])&.location_id ||
494
+ Lab::OrderLocationResolver.location_from_name(order_params['target_lab'])&.location_id
495
+ end
496
+
497
+ def order_observation(order, concept_name)
498
+ concept = ConceptName.where(name: concept_name).select(:concept_id)
499
+ Observation.unscoped.where(order_id: order.order_id, concept_id: concept, voided: 0)
500
+ .order(:date_created, :obs_id)
501
+ .first
502
+ end
503
+
476
504
  def create_initial_order_status_trail(order)
477
505
  create_order_status_observation(
478
506
  order: order,
@@ -525,8 +553,8 @@ module Lab
525
553
  concept = Lab::Lims::Utils.find_concept_by_name(test_name)
526
554
  next unless concept
527
555
 
528
- # Find the test observation
529
- test = order.tests.find_by(value_coded: concept.concept_id)
556
+ # Find the test observation without the daemon's current-location scope.
557
+ test = Lab::LabTest.unscoped.find_by(order_id: order.order_id, value_coded: concept.concept_id, voided: 0)
530
558
  next unless test
531
559
 
532
560
  # Save each status trail entry
@@ -561,6 +589,8 @@ module Lab
561
589
  voided: 0
562
590
  )
563
591
 
592
+ location_id = Lab::OrderLocationResolver.location_id_for_order(order)
593
+
564
594
  # Create status observation
565
595
  Observation.create!(
566
596
  person_id: order.patient_id,
@@ -571,6 +601,7 @@ module Lab
571
601
  obs_datetime: timestamp,
572
602
  comments: updated_by.to_json,
573
603
  creator: User.current&.user_id || 1,
604
+ location_id:,
574
605
  date_created: Time.now,
575
606
  uuid: SecureRandom.uuid
576
607
  )
@@ -599,6 +630,8 @@ module Lab
599
630
  voided: 0
600
631
  )
601
632
 
633
+ location_id = Lab::OrderLocationResolver.location_id_for_test(test)
634
+
602
635
  # Create status observation
603
636
  Observation.create!(
604
637
  person_id: test.person_id,
@@ -609,6 +642,7 @@ module Lab
609
642
  obs_datetime: timestamp,
610
643
  comments: updated_by.to_json,
611
644
  creator: User.current&.user_id || 1,
645
+ location_id:,
612
646
  date_created: Time.now,
613
647
  uuid: SecureRandom.uuid
614
648
  )
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'order_location_resolver'
4
+
3
5
  module Lab
4
6
  module ResultsService
5
7
  class << self
@@ -20,16 +22,15 @@ module Lab
20
22
  serializer = {}
21
23
  results_obs = {}
22
24
  ActiveRecord::Base.transaction do
23
- test = begin
24
- Lab::LabTest.find(test_id)
25
- rescue StandardError
26
- nil
27
- end
28
- test = Lab::LabTest.find_by_uuid(test_id) if test.blank?
25
+ test = Lab::LabTest.unscoped.find_by(obs_id: test_id)
26
+ test ||= Lab::LabTest.unscoped.find_by(uuid: test_id)
27
+ raise ActiveRecord::RecordNotFound, "Couldn't find Lab::LabTest with id=#{test_id}" unless test
28
+
29
29
  encounter = find_encounter(test, encounter_id: params[:encounter_id],
30
30
  encounter_uuid: params[:encounter],
31
31
  date: params[:date]&.to_date,
32
- provider_id: params[:provider_id])
32
+ provider_id: params[:provider_id],
33
+ location_id: params[:location_id])
33
34
 
34
35
  results_obs = create_results_obs(encounter, test, params[:date], params[:comments])
35
36
  params[:measures].map { |measure| add_measure_to_results(results_obs, measure, params[:date]) }
@@ -105,22 +106,25 @@ module Lab
105
106
  .first&.identifier
106
107
  end
107
108
 
108
- def find_encounter(test, encounter_id: nil, encounter_uuid: nil, date: nil, provider_id: nil)
109
- return Encounter.find(encounter_id) if encounter_id
110
- return Encounter.find_by_uuid(encounter_uuid) if encounter_uuid
109
+ def find_encounter(test, encounter_id: nil, encounter_uuid: nil, date: nil, provider_id: nil, location_id: nil)
110
+ return Encounter.unscoped.find(encounter_id) if encounter_id
111
+ return Encounter.unscoped.find_by_uuid(encounter_uuid) if encounter_uuid
111
112
 
112
113
  lab_encounter_type = EncounterType.find_by_name!(Lab::Metadata::ENCOUNTER_TYPE_NAME)
114
+ source_encounter = Encounter.unscoped.find_by(encounter_id: test.encounter_id)
115
+ location_id ||= Lab::OrderLocationResolver.location_id_for_test(test)
113
116
 
114
117
  encounter = Encounter.new
115
118
  encounter.patient_id = test.person_id
116
- encounter.program_id = test.encounter.program_id if Encounter.column_names.include?('program_id')
117
- encounter.visit_id = test.encounter.visit_id if Encounter.column_names.include?('visit_id')
119
+ encounter.program_id = source_encounter&.program_id if Encounter.column_names.include?('program_id')
120
+ encounter.visit_id = source_encounter&.visit_id if Encounter.column_names.include?('visit_id')
118
121
  # Use bracket notation to set the encounter_type column directly (bypasses association)
119
122
  # This handles both Integer and EncounterType object
120
123
  encounter_type_value = lab_encounter_type.is_a?(Integer) ? lab_encounter_type : lab_encounter_type.encounter_type_id
121
124
  encounter[:encounter_type] = encounter_type_value
122
125
  encounter.encounter_datetime = date || Date.today
123
126
  encounter.provider_id = provider_id || User.current.user_id if Encounter.column_names.include?('provider_id')
127
+ encounter.location_id = location_id if Encounter.column_names.include?('location_id') && location_id.present?
124
128
  encounter.save!
125
129
  encounter.reload
126
130
  encounter
@@ -130,20 +134,23 @@ module Lab
130
134
  def create_results_obs(encounter, test, date, comments = nil)
131
135
  void_existing_results_obs(encounter, test)
132
136
  Lab::LabResult.create!(
137
+ test:,
133
138
  person_id: encounter.patient_id,
134
139
  encounter_id: encounter.encounter_id,
135
140
  concept_id: test_result_concept.concept_id,
136
141
  order_id: test.order_id,
137
142
  obs_group_id: test.obs_id,
143
+ location_id: encounter.location_id || Lab::OrderLocationResolver.location_id_for_test(test),
138
144
  obs_datetime: date&.to_datetime || DateTime.now,
139
145
  comments:
140
146
  )
141
147
  end
142
148
 
143
149
  def void_existing_results_obs(encounter, test)
144
- result = Lab::LabResult.find_by(person_id: encounter.patient_id,
145
- concept_id: test_result_concept.concept_id,
146
- obs_group_id: test.obs_id)
150
+ result = Lab::LabResult.unscoped.find_by(person_id: encounter.patient_id,
151
+ concept_id: test_result_concept.concept_id,
152
+ obs_group_id: test.obs_id,
153
+ voided: 0)
147
154
  return unless result
148
155
 
149
156
  OrderExtension.find_by(order_id: result.order_id)&.void("Updated/overwritten by #{User.current.username}")
@@ -167,6 +174,7 @@ module Lab
167
174
  order_id: results_obs.order_id,
168
175
  concept_id: concept_id,
169
176
  obs_group_id: results_obs.obs_id,
177
+ location_id: results_obs.location_id,
170
178
  obs_datetime: date&.to_datetime || DateTime.now,
171
179
  **make_measure_value(params)
172
180
  )
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'order_location_resolver'
4
+
3
5
  module Lab
4
6
  ##
5
7
  # Manage tests that have been ordered through the ordering service.
@@ -29,6 +31,7 @@ module Lab
29
31
  raise InvalidParameterError, 'tests are required' if tests_params.nil? || tests_params.empty?
30
32
 
31
33
  Lab::LabTest.transaction do
34
+ location_id = Lab::OrderLocationResolver.location_id_for_order(order)
32
35
  tests_params.map do |params|
33
36
  concept_id = params[:concept_id]
34
37
  concept_id = Concept.find_concept_by_uuid(params[:concept]).id if concept_id.nil?
@@ -40,6 +43,7 @@ module Lab
40
43
  order_id: order.order_id,
41
44
  person_id: order.patient_id,
42
45
  obs_datetime: date&.to_time || Time.now,
46
+ location_id:,
43
47
  value_coded: concept_id
44
48
  )
45
49
 
@@ -123,6 +127,7 @@ module Lab
123
127
  )
124
128
 
125
129
  # Create status observation with 'Drawn' as initial status
130
+ location_id = Lab::OrderLocationResolver.location_id_for_test(test)
126
131
  Observation.create!(
127
132
  person_id: test.person_id,
128
133
  encounter_id: test.encounter_id,
@@ -137,6 +142,7 @@ module Lab
137
142
  'phone_number' => nil
138
143
  }.to_json,
139
144
  creator: User.current&.user_id || 1,
145
+ location_id:,
140
146
  date_created: Time.now,
141
147
  uuid: SecureRandom.uuid
142
148
  )
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.4.1'
4
+ VERSION = '2.4.2-beta'.freeze
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.4.1
4
+ version: 2.4.2.pre.beta
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-06-26 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: couchrest
@@ -286,6 +286,7 @@ files:
286
286
  - app/services/lab/lims/worker.rb
287
287
  - app/services/lab/metadata.rb
288
288
  - app/services/lab/notification_service.rb
289
+ - app/services/lab/order_location_resolver.rb
289
290
  - app/services/lab/orders_search_service.rb
290
291
  - app/services/lab/orders_service.rb
291
292
  - app/services/lab/results_service.rb
@@ -341,9 +342,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
341
342
  version: '0'
342
343
  required_rubygems_version: !ruby/object:Gem::Requirement
343
344
  requirements:
344
- - - ">="
345
+ - - ">"
345
346
  - !ruby/object:Gem::Version
346
- version: '0'
347
+ version: 1.3.1
347
348
  requirements: []
348
349
  rubygems_version: 3.4.1
349
350
  signing_key: