his_emr_api_lab 0.0.2 → 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
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