his_emr_api_lab 2.4.8.pre.alpha → 2.4.9

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: c976558db9507288b37c309d50e9b45ccb70b9848483c86c980e605906464823
4
- data.tar.gz: 88223e508a7f67c51afa7c40f6b2fb2d758c9afe868c32e8a56dd44b567db8f9
3
+ metadata.gz: 2f0e2b1fd0f7372571d684a23a529651631e55a271d1355aa1d12f8b7d2a85d5
4
+ data.tar.gz: 6509d471a8f57e1108430e3d6e08a4308b5bca2e0eb386d1088534f0c0863856
5
5
  SHA512:
6
- metadata.gz: 357768b64d3e2bd5308e71816b7e38f17883a504376a41275bd609f06d66d5ec21fa51ff9ce0734a6abce99b1aeb103dec484a87623e45f1a61e9bcac76a4c3e
7
- data.tar.gz: 7e27bf14bc5ece4ab4a4a55145d8ec3492c7b677f7bfd40530fb978cd40492b72579ccea8c4023fd64cc3976d949ddc37993af8195cbdfcbd90f25a0ecc87bdd
6
+ metadata.gz: 2500b6aa2b1cc75bc8476a4ed68576faf0156cc7f836b7fd3039606db135c05672f957fe4188d69f994b9fc54c952fb39974dcd398dee0376af32e78b5f9f0ab
7
+ data.tar.gz: f74c867864d8c77e8b73ec31b8ac0dd82d5b6d77d4c6152d32cbf3c010e909d24c844839153d4670f14bcfaa9db1e45de6f5d767176d70b262321eec8603b9f4
@@ -22,8 +22,8 @@ module Lab
22
22
 
23
23
  def update
24
24
  specimen = params.require(:specimen).slice(:concept_id)
25
- order = OrdersService.update_order(params[:id], specimen:, force_update: params[:force_update])
26
-
25
+ order = OrdersService.update_order(params[:id], specimen:, force_update: params[:force_update], date: params[:date])
26
+
27
27
  begin
28
28
  Lab::PushOrderJob.perform_later(order.fetch(:order_id))
29
29
  rescue StandardError => e
@@ -66,6 +66,10 @@ module Lab
66
66
  .where.not(concept_id: ConceptName.where(name: 'Tests ordered').select(:concept_id))
67
67
  end
68
68
 
69
+ # Only checked when start_date is set or changed, so existing future-dated
70
+ # orders can still be voided or updated during clean-up.
71
+ validate :start_date_cannot_be_in_the_future, if: :will_save_change_to_start_date?
72
+
69
73
  scope :drawn, -> { where.not(concept_id: ConceptName.where(name: 'Unknown').select(:concept_id)) }
70
74
  scope :not_drawn, -> { where(concept_id: ConceptName.where(name: 'Unknown').select(:concept_id)) }
71
75
 
@@ -78,5 +82,13 @@ module Lab
78
82
  :comment_to_fulfiller,
79
83
  :tests)
80
84
  end
85
+
86
+ # Allows up to tomorrow to cover timezone differences, matching
87
+ # Lab::Lims::Utils.parse_date.
88
+ def start_date_cannot_be_in_the_future
89
+ return if start_date.blank? || start_date.to_date <= Date.current + 1.day
90
+
91
+ errors.add(:start_date, 'cannot be in the future')
92
+ end
81
93
  end
82
94
  end
@@ -9,6 +9,7 @@ module Lab
9
9
  class UnknownSpecimenType < LimsException; end
10
10
  class UnknownTestType < LimsException; end
11
11
  class ValidationUnavailable < LimsException; end
12
+ class InvalidDate < LimsException; end
12
13
  end
13
14
  end
14
15
  end
@@ -21,5 +22,6 @@ module Lab
21
22
  class UnknownSpecimenType < LimsException; end
22
23
  class UnknownTestType < LimsException; end
23
24
  class ValidationUnavailable < LimsException; end
25
+ class InvalidDate < LimsException; end
24
26
  end
25
27
  end
@@ -12,6 +12,8 @@ module Lab
12
12
  include Utils # for logger
13
13
 
14
14
  LIMS_LOG_PATH = Rails.root.join('log', 'lims')
15
+ UNMAPPED_ORDER_REASON = 'Order not mapped to LIMS: pull worker does not create orders'
16
+ DECEASED_PATIENT_REASON = "Order dated after patient's death"
15
17
 
16
18
  def initialize(lims_api, start_date: nil, accession_numbers: [], patient_id: nil)
17
19
  @lims_api = lims_api
@@ -171,47 +173,84 @@ module Lab
171
173
  name1.casecmp?(name2)
172
174
  end
173
175
 
176
+ ##
177
+ # Applies a LIMS order to the local order it is mapped to.
178
+ #
179
+ # Orders are only ever created in the EMR, never by the pull worker.
180
+ # find_patient_by_nhid only lets an order through when a local order with
181
+ # the same accession number already exists, so creating one here would
182
+ # always produce a duplicate. Orders without a LIMS mapping (e.g. legacy
183
+ # orders, or new orders the push worker has not sent yet) are skipped and
184
+ # recorded once in lab_lims_failed_imports for review. So are orders
185
+ # dated after the patient's death.
186
+ #
187
+ # Returns the updated order, or nil if the order was skipped.
174
188
  def save_order(patient, order_dto)
175
189
  raise MissingAccessionNumber if order_dto[:tracking_number].blank?
176
190
 
177
191
  logger.info("Importing LIMS order ##{order_dto[:tracking_number]}")
192
+
193
+ death_date = death_date_before_order(patient, order_dto)
194
+ if death_date
195
+ order_date = lims_order_date(order_dto)
196
+ logger.warn("Not importing order ##{order_dto[:tracking_number]}: dated #{order_date}, after patient's death on #{death_date}")
197
+ record_skipped_order(order_dto, DECEASED_PATIENT_REASON, { order_date:, death_date: })
198
+ return nil
199
+ end
200
+
178
201
  mapping = find_order_mapping_by_lims_id(order_dto[:_id])
179
202
 
203
+ unless mapping
204
+ logger.warn("Not creating order ##{order_dto[:tracking_number]} from LIMS: no LIMS mapping for local order")
205
+ record_skipped_order(order_dto, UNMAPPED_ORDER_REASON)
206
+ return nil
207
+ end
208
+
180
209
  ActiveRecord::Base.transaction do
181
- if mapping
182
- order = update_order(patient, mapping.order_id, order_dto)
183
- mapping.update(pulled_at: Time.now)
184
- else
185
- order = create_order(patient, order_dto)
186
- mapping = LimsOrderMapping.create(lims_id: order_dto[:_id],
187
- order_id: order['id'],
188
- pulled_at: Time.now,
189
- revision: order_dto['_rev'])
190
- end
210
+ order = update_order(patient, mapping.order_id, order_dto)
211
+ mapping.update(pulled_at: Time.now)
191
212
 
192
213
  order
193
214
  end
194
215
  end
195
216
 
196
- def create_order(patient, order_dto)
197
- logger.debug("Creating order ##{order_dto['_id']}")
198
- params = order_dto.to_order_service_params(patient_id: patient.patient_id)
199
- params[:location_id] ||= location_id_for_order_dto(order_dto)
200
- order = OrdersService.order_test(params)
217
+ ##
218
+ # Returns the patient's death date if the order is dated after it, else nil.
219
+ #
220
+ # Orders dated on or before the death date are still imported, so results
221
+ # for samples drawn before death keep arriving.
222
+ def death_date_before_order(patient, order_dto)
223
+ person = Person.unscoped.find_by(person_id: patient.patient_id)
224
+ return nil unless person && ActiveModel::Type::Boolean.new.cast(person.dead) && person.death_date
225
+
226
+ death_date = person.death_date.to_date
227
+ death_date if lims_order_date(order_dto) > death_date
228
+ end
201
229
 
202
- # Extract and save status trails from NLIMS
203
- save_status_trails_from_nlims(order, order_dto)
230
+ def lims_order_date(order_dto)
231
+ raise LimsException, 'Order missing created date' if order_dto[:date_created].blank?
204
232
 
205
- # Update results if present
206
- update_results(order, order_dto['test_results']) unless order_dto['test_results'].empty?
233
+ Utils.parse_date(order_dto[:date_created]).to_date
234
+ end
207
235
 
208
- order
236
+ # The REST pull re-selects skipped orders on every run, so each one is
237
+ # recorded only once instead of adding a row per run.
238
+ def record_skipped_order(order_dto, reason, diff = nil)
239
+ LimsFailedImport.find_or_create_by!(lims_id: order_dto[:_id],
240
+ tracking_number: order_dto[:tracking_number],
241
+ reason:) do |failed_import|
242
+ failed_import.patient_nhid = order_dto.dig(:patient, :id)
243
+ failed_import.diff = diff&.to_json
244
+ end
245
+ rescue StandardError => e
246
+ logger.error("Failed to record skipped order ##{order_dto[:tracking_number]}: #{e.message}")
209
247
  end
210
248
 
211
249
  def update_order(patient, order_id, order_dto)
212
250
  logger.debug("Updating order ##{order_dto['_id']}")
213
251
  params = order_dto.to_order_service_params(patient_id: patient.patient_id)
214
252
  params[:location_id] ||= Lab::OrderLocationResolver.location_id_for_order_id(order_id, facility_name: order_dto[:sending_facility])
253
+ keep_local_specimen(order_id, params) unless specimen_update_allowed?(order_id)
215
254
  order = OrdersService.update_order(order_id, params.merge(force_update: 'true'))
216
255
 
217
256
  # Extract and save status trails from NLIMS
@@ -223,6 +262,38 @@ module Lab
223
262
  order
224
263
  end
225
264
 
265
+ ##
266
+ # LIMS may only set the specimen of an order whose sample has not been
267
+ # drawn yet (specimen 'Unknown') and that has no results. Any other order
268
+ # keeps its local specimen, so a sync can never change or discontinue an
269
+ # order that has already been drawn or resulted.
270
+ def specimen_update_allowed?(order_id)
271
+ order = Lab::LabOrder.unscoped.find(order_id)
272
+
273
+ order.concept_id == unknown_specimen_concept_id && !order_has_results?(order_id)
274
+ end
275
+
276
+ def keep_local_specimen(order_id, params)
277
+ local_specimen_id = Lab::LabOrder.unscoped.find(order_id).concept_id
278
+ lims_specimen_id = params.dig(:specimen, :concept_id)
279
+
280
+ if lims_specimen_id.present? && lims_specimen_id.to_i != local_specimen_id
281
+ logger.info("Keeping local specimen ##{local_specimen_id} on order ##{order_id}, ignoring LIMS specimen ##{lims_specimen_id}")
282
+ end
283
+
284
+ params[:specimen] = { concept_id: local_specimen_id }
285
+ end
286
+
287
+ def unknown_specimen_concept_id
288
+ @unknown_specimen_concept_id ||= ConceptName.find_by(name: Lab::Metadata::UNKNOWN_SPECIMEN)&.concept_id
289
+ end
290
+
291
+ def order_has_results?(order_id)
292
+ result_concept = ConceptName.where(name: Lab::Metadata::TEST_RESULT_CONCEPT_NAME).select(:concept_id)
293
+
294
+ Observation.unscoped.where(order_id:, concept_id: result_concept, voided: 0).exists?
295
+ end
296
+
226
297
  def update_results(order, lims_results)
227
298
  logger.debug("Updating results for order ##{order[:accession_number]}: #{lims_results}")
228
299
 
@@ -266,12 +337,6 @@ module Lab
266
337
  LabTest.unscoped.find_by(order_id:, value_coded: test_concept.concept_id, voided: 0)
267
338
  end
268
339
 
269
- def location_id_for_order_dto(order_dto)
270
- local_order = Lab::LabOrder.unscoped.find_by(accession_number: order_dto[:tracking_number])
271
-
272
- Lab::OrderLocationResolver.location_id_for_order(local_order, facility_name: order_dto[:sending_facility])
273
- end
274
-
275
340
  def find_measure(_order, indicator_name, value)
276
341
  indicator = Utils.find_concept_by_name(indicator_name)
277
342
  unless indicator
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'cgi/util'
4
4
 
5
+ require_relative 'exceptions'
6
+
5
7
  module Lab
6
8
  module Lims
7
9
  ##
@@ -54,6 +56,16 @@ module Lab
54
56
  User.create!(username: 'lab_daemon', person:, creator: god_user.user_id)
55
57
  end
56
58
 
59
+ # Earliest date accepted from LIMS. Anything older is a corrupted
60
+ # legacy date (e.g. year 0026 or 18xx), not a real lab order.
61
+ EARLIEST_VALID_DATE = Date.new(2000, 1, 1)
62
+
63
+ ##
64
+ # Parses a LIMS date, accepting only real calendar dates with a
65
+ # four-digit year between EARLIEST_VALID_DATE and tomorrow (to allow for
66
+ # timezone differences). Invalid dates are never guessed at or rewritten:
67
+ # the fallback date is used if one is given, otherwise InvalidDate is
68
+ # raised.
57
69
  def self.parse_date(str_date, fallback_date = nil)
58
70
  str_date = str_date&.to_s
59
71
 
@@ -61,21 +73,35 @@ module Lab
61
73
 
62
74
  return parse_date(fallback_date) if str_date.blank?
63
75
 
64
- str_date = str_date.gsub(/^00/, '20').gsub(/^180/, '20')
65
-
66
- case str_date
67
- when /\d{4}-\d{2}-\d{2}/
68
- str_date
69
- when /\d{2}-\d{2}-\d{2}/
70
- Date.strptime(str_date, '%d-%m-%Y').strftime('%Y-%m-%d')
71
- when /(\d{4}\d{2}\d{2})\d+/
72
- Date.strptime(str_date, '%Y%m%d').strftime('%Y-%m-%d')
73
- when %r{\d{2}/\d{2}/\d{4}}
74
- str_date.to_date.to_s
75
- else
76
- Rails.logger.warn("Invalid date: #{str_date}")
77
- parse_date(fallback_date)
76
+ date = begin
77
+ case str_date
78
+ when /\d{4}-\d{2}-\d{2}/
79
+ str_date
80
+ when /\d{2}-\d{2}-\d{4}/
81
+ Date.strptime(str_date, '%d-%m-%Y').strftime('%Y-%m-%d')
82
+ when /(\d{4}\d{2}\d{2})\d+/
83
+ Date.strptime(str_date, '%Y%m%d').strftime('%Y-%m-%d')
84
+ when %r{\d{2}/\d{2}/\d{4}}
85
+ str_date.to_date.to_s
86
+ end
87
+ rescue Date::Error, ArgumentError
88
+ nil
78
89
  end
90
+
91
+ return date if valid_date?(date)
92
+
93
+ Rails.logger.warn("Invalid date: #{str_date}")
94
+ raise InvalidDate, "Invalid date: #{str_date}" if fallback_date.blank?
95
+
96
+ parse_date(fallback_date)
97
+ end
98
+
99
+ def self.valid_date?(date)
100
+ return false if date.blank?
101
+
102
+ date.to_date.between?(EARLIEST_VALID_DATE, Date.current + 1.day)
103
+ rescue Date::Error, ArgumentError
104
+ false
79
105
  end
80
106
 
81
107
  def self.find_concept_by_name(name)
@@ -79,6 +79,8 @@ module Lab
79
79
  end
80
80
 
81
81
  def creator_location_for_order(order)
82
+ return nil unless User.column_names.include?('location_id')
83
+
82
84
  creator_id = record_creator_id(order)
83
85
  return nil if creator_id.blank?
84
86
 
@@ -249,7 +249,7 @@ module Lab
249
249
  orders = Lab::LabOrder.where(order_id: tests.pluck(:order_id))
250
250
  data = {
251
251
  count: orders.count,
252
- last_order_date: Lab::LabOrder.last&.start_date&.to_date,
252
+ last_order_date: last_order_date(location_id),
253
253
  lab_orders: []
254
254
  }
255
255
  if (include_data.present? && include_data == 'true')
@@ -266,6 +266,19 @@ module Lab
266
266
 
267
267
  private
268
268
 
269
+ # Without a location this is the latest lab order overall (unchanged behaviour). With a location
270
+ # it is the latest lab order whose tests were drawn there, so a central EMR serving many sites
271
+ # reports each site's own last order instead of the latest order across all sites.
272
+ def last_order_date(location_id)
273
+ return Lab::LabOrder.last&.start_date&.to_date if location_id.blank?
274
+
275
+ order_ids = Lab::LabTest.unscoped
276
+ .where(voided: false, location_id:,
277
+ concept: ConceptName.where(name: Lab::Metadata::TEST_TYPE_CONCEPT_NAME))
278
+ .select(:order_id)
279
+ Lab::LabOrder.where(order_id: order_ids).maximum(:start_date)&.to_date
280
+ end
281
+
269
282
  def create_rejection_notification(order_params)
270
283
  order = find_order order_params['tracking_number']
271
284
  data = { type: 'LIMS',
@@ -373,7 +386,7 @@ module Lab
373
386
  end
374
387
 
375
388
  def accession_number_exists?(accession_number)
376
- Lab::LabOrder.where(accession_number:).exists?
389
+ Lab::LabOrder.unscoped.where(accession_number:, voided: 0).exists?
377
390
  end
378
391
 
379
392
  def nlims_accession_number_exists?(accession_number)
@@ -17,6 +17,8 @@ module Lab
17
17
  def authenticate_user(username:, password:, user_agent:, request_ip:)
18
18
  # Use unscoped for authentication - should not be location-dependent
19
19
  user = User.unscoped.find_by_username username
20
+ return nil if user.nil?
21
+
20
22
  encrypted_pass = Password.new(user.password)
21
23
  if encrypted_pass == password
22
24
  generate_token(user, user_agent, request_ip)
@@ -24,6 +26,9 @@ module Lab
24
26
  # throw authentication error
25
27
  nil
26
28
  end
29
+ rescue BCrypt::Errors::InvalidHash
30
+ # Regular EMR users have SHA passwords; only users created through the LIMS user endpoint can log in here
31
+ nil
27
32
  end
28
33
 
29
34
  private
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.8-alpha'.freeze
4
+ VERSION = '2.4.9'.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.8.pre.alpha
4
+ version: 2.4.9
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-10-02 00:00:00.000000000 Z
11
+ date: 2026-10-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: couchrest
@@ -328,9 +328,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
328
328
  version: '0'
329
329
  required_rubygems_version: !ruby/object:Gem::Requirement
330
330
  requirements:
331
- - - ">"
331
+ - - ">="
332
332
  - !ruby/object:Gem::Version
333
- version: 1.3.1
333
+ version: '0'
334
334
  requirements: []
335
335
  rubygems_version: 3.4.1
336
336
  signing_key: