his_emr_api_lab 0.0.2 → 0.0.7

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: 8cc795d01dc073e4556a1070e8e14199e49adff9419dfe02837008adb1aa6556
4
- data.tar.gz: 9a61181925a0df93287b43446f0ace3b552972859f5f61248f7b8fb0fb085f56
3
+ metadata.gz: 2de242e5fc5982f097e27abb3b4c85ac45b3eb8296ba53bf285ebe8a3422d486
4
+ data.tar.gz: '064059e7286ba44485e1707c2be889126776e15125732859bc41c682d4784002'
5
5
  SHA512:
6
- metadata.gz: 4a571f53b3c2216c9bdb60bfc98f8b02ecef5053d72ac91d9a4680d8adb893a4085cd4b91c9fb873552f4e0e84b7e6990321140e0fe9795e1c64bf45c2bf5fb2
7
- data.tar.gz: e169300a7f5e7b4c3755107895522275eaa3b71d17495cd14cac3cd5227d6b1f2529f8a27695f5301cbd7d92f20917c9e37dbd66be0babf49cdd98c3b3be3674
6
+ metadata.gz: c2bff8f28b0319d9cb19120704738a5cad5820cc8036abb083666d40d3b5f07d1c9d460b9c5b93bc5c7fe5cf393ac091196d0042bbf50730ad812dc1cad0979e
7
+ data.tar.gz: 8c1b11e868f38fcd9c61d275b2bd74783dd698eaaea2ee88d2819b76c944c36972856090a239f7087b3c96a65de7104fd65347ce7128f3c76d799768dbd80253
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ class LabelsController < ApplicationController
5
+ skip_before_action :authenticate
6
+
7
+ def print_order_label
8
+ order_id = params.require(:order_id)
9
+
10
+ label = LabellingService::OrderLabel.new(order_id)
11
+ send_data(label.print, type: 'application/label; charset=utf-8',
12
+ stream: false,
13
+ filename: "#{SecureRandom.hex(24)}.lbl",
14
+ disposition: 'inline')
15
+ end
16
+ end
17
+ end
@@ -14,7 +14,8 @@ module Lab
14
14
  def update
15
15
  specimen = params.require(:specimen).permit(:concept_id)
16
16
 
17
- order = OrdersService.update_order(params[:id], specimen: specimen)
17
+ order = OrdersService.update_order(params[:id], specimen: specimen,
18
+ force_update: params[:force_update])
18
19
 
19
20
  render json: order
20
21
  end
@@ -35,7 +35,11 @@ module Lab
35
35
  class_name: 'Observation',
36
36
  foreign_key: :order_id
37
37
 
38
- default_scope { joins(:order_type).merge(OrderType.where(name: Lab::Metadata::ORDER_TYPE_NAME)) }
38
+ default_scope do
39
+ joins(:order_type)
40
+ .merge(OrderType.where(name: Lab::Metadata::ORDER_TYPE_NAME))
41
+ .where.not(concept_id: ConceptName.where(name: 'Tests ordered').select(:concept_id))
42
+ end
39
43
 
40
44
  def self.prefetch_relationships
41
45
  includes(:reason_for_test,
@@ -17,5 +17,15 @@ module Lab
17
17
  end),
18
18
  class_name: 'Observation',
19
19
  foreign_key: :obs_group_id
20
+
21
+ def void(reason)
22
+ children.each do |measure|
23
+ # Need to have a LabResultMeasure model that privately handles it's children
24
+ measure.children.each { |provider| provider.void(reason) }
25
+ measure.void(reason)
26
+ end
27
+
28
+ super(reason)
29
+ end
20
30
  end
21
31
  end
@@ -10,5 +10,10 @@ module Lab
10
10
  -> { where(concept: ConceptName.where(name: Lab::Metadata::TEST_RESULT_CONCEPT_NAME)) },
11
11
  class_name: '::Lab::LabResult',
12
12
  foreign_key: :obs_group_id
13
+
14
+ def void(reason)
15
+ result&.void(reason)
16
+ super(reason)
17
+ end
13
18
  end
14
19
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'auto12epl'
4
+
5
+ module Lab
6
+ module LabellingService
7
+ ##
8
+ # Prints an order label for order with given accession number.
9
+ class OrderLabel
10
+ attr_reader :order
11
+
12
+ def initialize(order_id)
13
+ @order = Lab::LabOrder.find(order_id)
14
+ end
15
+
16
+ def print
17
+ # NOTE: The arguments are passed into the method below not in the order
18
+ # the method expects (eg patient_id is passed to middle_name field)
19
+ # to retain compatibility with labels generated by the `lab test controller`
20
+ # application of the NLIMS suite.
21
+ auto12epl.generate_epl(patient.given_name,
22
+ patient.family_name,
23
+ patient.nhid,
24
+ patient.birthdate.strftime('%d/%^b/%Y'),
25
+ '',
26
+ patient.gender,
27
+ '',
28
+ drawer,
29
+ '',
30
+ specimen,
31
+ reason_for_test,
32
+ order.accession_number,
33
+ order.accession_number)
34
+ end
35
+
36
+ def reason_for_test
37
+ return 'Unknown' unless order.reason_for_test
38
+
39
+ ConceptName.find_by_concept_id(order.reason_for_test.value_coded)&.name || 'Unknown'
40
+ end
41
+
42
+ def patient
43
+ return @patient if @patient
44
+
45
+ person = Person.find(order.patient_id)
46
+ person_name = PersonName.find_by_person_id(order.patient_id)
47
+ patient_identifier = PatientIdentifier.where(type: PatientIdentifierType.where(name: 'National id'),
48
+ patient_id: order.patient_id)
49
+ .first
50
+
51
+ @patient = OpenStruct.new(
52
+ given_name: person_name.given_name,
53
+ family_name: person_name.family_name,
54
+ birthdate: person.birthdate,
55
+ gender: person.gender,
56
+ nhid: patient_identifier&.identifier || 'Unknown'
57
+ )
58
+ end
59
+
60
+ def drawer
61
+ return 'N/A' if order.concept_id == unknown_concept.concept_id
62
+
63
+ name = PersonName.find_by_person_id(order.creator)
64
+ return "#{name.given_name} #{name.family_name}" if name
65
+
66
+ user = User.find(order.creator)
67
+ user&.username || 'N/A'
68
+ end
69
+
70
+ def specimen
71
+ return 'N/A' if order.concept_id == unknown_concept.concept_id
72
+
73
+ ConceptName.find_by_concept_id(order.concept_id)&.name || 'Unknown'
74
+ end
75
+
76
+ def unknown_concept
77
+ ConceptName.find_by_name('Unknown')
78
+ end
79
+
80
+ def auto12epl
81
+ Auto12Epl.new
82
+ end
83
+ end
84
+ end
85
+ end
@@ -30,6 +30,8 @@ module Lab
30
30
  # by calling method +choke+.
31
31
  def consume_orders(from: 0, limit: 30)
32
32
  bum.binge_changes(since: from, limit: limit, include_docs: true) do |change|
33
+ next unless change['doc']['type']&.casecmp?('Order')
34
+
33
35
  yield OrderDTO.new(change['doc']), self
34
36
  end
35
37
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ class LimsException < StandardError; end
6
+ class DuplicateNHID < LimsException; end
7
+ class MissingAccessionNumber < LimsException; end
8
+ class UnknownSpecimenType < LimsException; end
9
+ class UnknownTestType < LimsException; end
10
+ end
11
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require 'parallel'
5
+
6
+ require 'couch_bum/couch_bum'
7
+ require 'logger_multiplexor'
8
+
9
+ require 'concept'
10
+ require 'concept_name'
11
+ require 'drug_order'
12
+ require 'encounter'
13
+ require 'encounter_type'
14
+ require 'observation'
15
+ require 'order'
16
+ require 'order_type'
17
+ require 'patient'
18
+ require 'patient_identifier'
19
+ require 'patient_identifier_type'
20
+ require 'person'
21
+ require 'person_name'
22
+ require 'program'
23
+ require 'user'
24
+
25
+ require 'lab/lab_encounter'
26
+ require 'lab/lab_order'
27
+ require 'lab/lab_result'
28
+ require 'lab/lab_test'
29
+ require 'lab/lims_order_mapping'
30
+ require 'lab/lims_failed_import'
31
+
32
+ require_relative './worker'
33
+ require_relative '../orders_service'
34
+ require_relative '../results_service'
35
+ require_relative '../tests_service'
36
+ require_relative '../../../serializers/lab/lab_order_serializer'
37
+ require_relative '../../../serializers/lab/result_serializer'
38
+ require_relative '../../../serializers/lab/test_serializer'
39
+
40
+ require_relative 'order_dto'
41
+ require_relative 'utils'
42
+
43
+ module Lab
44
+ module Lims
45
+ module Migrator
46
+ class MigratorApi < Api
47
+ MAX_THREADS = 6
48
+
49
+ attr_reader :rejections
50
+
51
+ def consume_orders(from: nil, **_kwargs)
52
+ limit = 50_000
53
+
54
+ Parallel.each(read_orders(from, limit),
55
+ in_processes: MAX_THREADS,
56
+ finish: order_pmap_post_processor(from)) do |row|
57
+ next unless row['doc']['type']&.casecmp?('Order')
58
+
59
+ User.current = Utils.lab_user
60
+ yield OrderDTO.new(row['doc']), OpenStruct.new(last_seq: (from || 0) + limit, current_seq: from)
61
+ end
62
+ end
63
+
64
+ def last_seq
65
+ return 0 unless File.exist?(last_seq_path)
66
+
67
+ File.open(last_seq_path, File::RDONLY) do |file|
68
+ last_seq = file.read&.strip
69
+ return last_seq.blank? ? nil : last_seq&.to_i
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def last_seq_path
76
+ LIMS_LOG_PATH.join('migration-last-id.dat')
77
+ end
78
+
79
+ def order_pmap_post_processor(last_seq)
80
+ lambda do |item, index, result|
81
+ save_last_seq(last_seq + index)
82
+ status, reason = result
83
+ next unless status == :rejected
84
+
85
+ (@rejections ||= []) << OpenStruct.new(order: OrderDTO.new(item['doc']), reason: reason)
86
+ end
87
+ end
88
+
89
+ def save_last_seq(last_seq)
90
+ return unless last_seq
91
+
92
+ File.open(last_seq_path, File::WRONLY | File::CREAT, 0o644) do |file|
93
+ Rails.logger.debug("Process ##{Parallel.worker_number}: Saving last seq: #{last_seq}")
94
+ file.flock(File::LOCK_EX)
95
+ file.write(last_seq.to_s)
96
+ file.flush
97
+ end
98
+ end
99
+
100
+ def save_rejection(order_dto, reason); end
101
+
102
+ def read_orders(from, batch_size)
103
+ Enumerator.new do |enum|
104
+ loop do
105
+ start_key_param = from ? "&skip=#{from}" : ''
106
+ url = "_all_docs?include_docs=true&limit=#{batch_size}#{start_key_param}"
107
+
108
+ Rails.logger.debug("#{MigratorApi}: Pulling orders from LIMS CouchDB: #{url}")
109
+ response = bum.couch_rest :get, url
110
+
111
+ from ||= 0
112
+
113
+ break from if response['rows'].empty?
114
+
115
+ response['rows'].each do |row|
116
+ enum.yield(row)
117
+ end
118
+
119
+ from += response['rows'].size
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ class MigrationWorker < Worker
126
+ protected
127
+
128
+ def last_seq
129
+ lims_api.last_seq
130
+ end
131
+
132
+ def update_last_seq(_last_seq); end
133
+ end
134
+
135
+ def self.save_csv(filename, rows:, headers: nil)
136
+ CSV.open(filename, File::WRONLY | File::CREAT) do |csv|
137
+ csv << headers if headers
138
+ rows.each { |row| csv << row }
139
+ end
140
+ end
141
+
142
+ MIGRATION_REJECTIONS_CSV_PATH = LIMS_LOG_PATH.join('migration-rejections.csv')
143
+
144
+ def self.export_rejections(rejections)
145
+ headers = ['doc_id', 'Accession number', 'NHID', 'First name', 'Last name', 'Reason']
146
+ rows = (rejections || []).map do |rejection|
147
+ [
148
+ rejection.order[:_id],
149
+ rejection.order[:tracking_number],
150
+ rejection.order[:patient][:id],
151
+ rejection.order[:patient][:first_name],
152
+ rejection.order[:patient][:last_name],
153
+ rejection.reason
154
+ ]
155
+ end
156
+
157
+ save_csv(MIGRATION_REJECTIONS_CSV_PATH, headers: headers, rows: rows)
158
+ end
159
+
160
+ MIGRATION_FAILURES_CSV_PATH = LIMS_LOG_PATH.join('migration-failures.csv')
161
+
162
+ def self.export_failures
163
+ headers = ['doc_id', 'Accession number', 'NHID', 'Reason', 'Difference']
164
+ rows = Lab::LimsFailedImport.all.map do |failure|
165
+ [
166
+ failure.lims_id,
167
+ failure.tracking_number,
168
+ failure.patient_nhid,
169
+ failure.reason,
170
+ failure.diff
171
+ ]
172
+ end
173
+
174
+ save_csv(MIGRATION_FAILURES_CSV_PATH, headers: headers, rows: rows)
175
+ end
176
+
177
+ MIGRATION_LOG_PATH = LIMS_LOG_PATH.join('migration.log')
178
+
179
+ def self.start_migration
180
+ log_dir = Rails.root.join('log/lims')
181
+ Dir.mkdir(log_dir) unless File.exist?(log_dir)
182
+
183
+ logger = LoggerMultiplexor.new(Logger.new($stdout), MIGRATION_LOG_PATH)
184
+ logger.level = :debug
185
+ Rails.logger = logger
186
+ ActiveRecord::Base.logger = logger
187
+ # CouchBum.logger = logger
188
+
189
+ api = MigratorApi.new
190
+ worker = MigrationWorker.new(api)
191
+
192
+ worker.pull_orders
193
+ ensure
194
+ api && export_rejections(api.rejections)
195
+ export_failures
196
+ end
197
+ end
198
+ end
199
+ end
@@ -1,153 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './exceptions'
4
+
3
5
  module Lab
4
6
  module Lims
5
7
  ##
6
8
  # LIMS' Data Transfer Object for orders
7
9
  class OrderDTO < ActiveSupport::HashWithIndifferentAccess
8
- class << self
9
- include Utils
10
-
11
- ##
12
- # Takes a Lab::LabOrder and serializes it into a DTO
13
- def from_order(order)
14
- serialized_order = structify(Lab::LabOrderSerializer.serialize_order(order))
15
-
16
- new(
17
- tracking_number: serialized_order.accession_number,
18
- sending_facility: current_facility_name,
19
- receiving_facility: serialized_order.target_lab,
20
- tests: serialized_order.tests.collect(&:name),
21
- patient: format_patient(serialized_order.patient_id),
22
- order_location: format_order_location(serialized_order.encounter_id),
23
- sample_type: format_sample_type(serialized_order.specimen.name),
24
- sample_status: format_sample_status(serialized_order.specimen.name),
25
- districy: current_district, # yes districy [sic]...
26
- priority: serialized_order.reason_for_test.name,
27
- date_created: serialized_order.order_date,
28
- test_results: format_test_results(serialized_order),
29
- type: 'Order'
30
- )
31
- end
32
-
33
- private
34
-
35
- def format_order_location(encounter_id)
36
- location_id = Encounter.select(:location_id).where(encounter_id: encounter_id)
37
- location = Location.select(:name)
38
- .where(location_id: location_id)
39
- .first
40
-
41
- location&.name
42
- end
43
-
44
- # Format patient into a structure that LIMS expects
45
- def format_patient(patient_id)
46
- person = Person.find(patient_id)
47
- name = PersonName.find_by_person_id(patient_id)
48
- national_id = PatientIdentifier.joins(:type)
49
- .merge(PatientIdentifierType.where(name: 'National ID'))
50
- .where(patient_id: patient_id)
51
- .first
52
- phone_number = PersonAttribute.joins(:type)
53
- .merge(PersonAttributeType.where(name: 'Cell phone Number'))
54
- .where(person_id: patient_id)
55
- .first
56
-
57
- {
58
- first_name: name&.given_name,
59
- last_name: name&.family_name,
60
- id: national_id&.value,
61
- phone_number: phone_number,
62
- gender: person.gender,
63
- email: nil
64
- }
65
- end
66
-
67
- def format_sample_type(name)
68
- name.casecmp?('Unknown') ? 'not_specified' : name
69
- end
70
-
71
- def format_sample_status(name)
72
- name.casecmp?('Unknown') ? 'specimen_not_collected' : 'specimen_collected'
73
- end
74
-
75
- def format_test_results(order)
76
- order.tests.each_with_object({}) do |test, results|
77
- results[test.name] = {
78
- results: test.result.each_with_object({}) do |measure, measures|
79
- measures[measure.indicator.name] = { result_value: "#{measure.value_modifier}#{measure.value}" }
80
- end,
81
- result_date: test.result.first&.date,
82
- result_entered_by: {}
83
- }
84
- end
85
- end
86
-
87
- def current_health_center
88
- health_center = Location.current_health_center
89
- raise 'Current health center not set' unless health_center
90
-
91
- health_center
92
- end
93
-
94
- def current_district
95
- unless current_health_center.parent
96
- raise "Current health center ##{current_health_center.id} is not associated with any district"
97
- end
98
-
99
- current_health_center.city_village || current_health_center.parent.name
100
- end
101
-
102
- def current_facility_name
103
- current_health_center.name
104
- end
105
- end
10
+ include Utils
106
11
 
107
12
  ##
108
13
  # Unpacks a LIMS order into an object that OrdersService can handle
109
- def to_order_service_params(lims_order)
14
+ def to_order_service_params(patient_id:)
110
15
  ActiveSupport::HashWithIndifferentAccess.new(
111
16
  program_id: lab_program.program_id,
112
- patient_id: patient.patient_id,
113
- specimen_type: { concept_id: specimen_type_id(lims_order.sample_type) },
114
- tests: lims_order.tests&.map { |test| { concept_id: test_type_id(test) } },
115
- requesting_clinician: requesting_clinician(lims_order.who_order_test),
116
- start_date: start_date(lims_order.date_created),
117
- target_lab: facility_name(lims_order.receiving_facility),
118
- order_location: facility_name(lims_order.sending_facility),
119
- reason_for_test: reason_for_test(lims_order.sample_priority)
17
+ accession_number: self['tracking_number'],
18
+ patient_id: patient_id,
19
+ specimen: { concept_id: specimen_type_id },
20
+ tests: self['tests']&.map { |test| { concept_id: test_type_id(test) } },
21
+ requesting_clinician: requesting_clinician,
22
+ date: start_date,
23
+ target_lab: facility_name(self['receiving_facility']),
24
+ order_location: facility_name(self['sending_facility']),
25
+ reason_for_test: reason_for_test
120
26
  )
121
27
  end
122
28
 
123
29
  private
124
30
 
125
31
  # Translates a LIMS specimen name to an OpenMRS concept_id
126
- def specimen_type_id(lims_specimen_name)
127
- if lims_specimen_name == 'specimen_not_collected'
128
- return ConceptName.select(:concept_id).find_by_name!('Unknown')
32
+ def specimen_type_id
33
+ lims_specimen_name = self['sample_type']&.strip&.downcase
34
+
35
+ if %w[specimen_not_collected not_assigned not_specified].include?(lims_specimen_name)
36
+ return ConceptName.select(:concept_id).find_by_name!('Unknown').concept_id
129
37
  end
130
38
 
131
- concept = ConceptName.select(:concept_id).find_by_name(lims_specimen_name)
39
+ concept = Utils.find_concept_by_name(lims_specimen_name)
132
40
  return concept.concept_id if concept
133
41
 
134
- raise "Unknown specimen name: #{lims_specimen_name}"
42
+ raise UnknownSpecimenType, "Unknown specimen name: #{lims_specimen_name}"
135
43
  end
136
44
 
137
45
  # Translates a LIMS test type name to an OpenMRS concept_id
138
46
  def test_type_id(lims_test_name)
139
- concept = ConceptName.select(:concept_id).find_by_name(lims_test_name)
47
+ lims_test_name = Utils.translate_test_name(lims_test_name)
48
+ concept = Utils.find_concept_by_name(lims_test_name)
140
49
  return concept.concept_id if concept
141
50
 
142
- raise "Unknown test type: #{lims_test_name}"
51
+ raise UnknownTestType, "Unknown test type: #{lims_test_name}"
143
52
  end
144
53
 
145
54
  # Extract requesting clinician name from LIMS
146
- def requesting_clinician(lims_user)
55
+ def requesting_clinician
56
+ return 'Unknown' unless self['who_order_test']
57
+
147
58
  # TODO: Extend requesting clinician to an obs tree having extra parameters
148
59
  # like phone number and ID to closely match the lims user.
149
- first_name = lims_user.first_name || ''
150
- last_name = lims_user.last_name || ''
60
+ first_name = self['who_order_test']['first_name'] || ''
61
+ last_name = self['who_order_test']['last_name'] || ''
151
62
 
152
63
  if first_name.blank? && last_name.blank?
153
64
  logger.warn('Missing requesting clinician name')
@@ -157,8 +68,8 @@ module Lab
157
68
  "#{first_name} #{last_name}"
158
69
  end
159
70
 
160
- def start_date(lims_order_date_created)
161
- lims_order_date_created.to_datetime
71
+ def start_date
72
+ Utils.parse_date(self['date_created'])
162
73
  end
163
74
 
164
75
  # Parses a LIMS facility name
@@ -168,9 +79,19 @@ module Lab
168
79
  lims_target_lab
169
80
  end
170
81
 
171
- # Translates a LIMS priority to a concept_id
172
- def reason_for_test(lims_sample_priority)
173
- ConceptName.find_by_name!(lims_sample_priority).concept_id
82
+ # Translates a LIMS sample priority to a concept_id
83
+ def reason_for_test
84
+ return unknown_concept.concept_id unless self['sample_priority']
85
+
86
+ ConceptName.find_by_name!(self['sample_priority']).concept_id
87
+ end
88
+
89
+ def lab_program
90
+ Program.find_by_name!(Lab::Metadata::LAB_PROGRAM_NAME)
91
+ end
92
+
93
+ def unknown_concept
94
+ ConceptName.find_by_name!('Unknown')
174
95
  end
175
96
  end
176
97
  end