his_emr_api_lab 0.0.2 → 0.0.3

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: 778184a052cac6932ec700f03b99615024440af3944c371d0cfa997f357c525c
4
+ data.tar.gz: 045fb73daab53abc457c08152c5148251a6dccb239f0c76e09f3172007e44b6e
5
5
  SHA512:
6
- metadata.gz: 4a571f53b3c2216c9bdb60bfc98f8b02ecef5053d72ac91d9a4680d8adb893a4085cd4b91c9fb873552f4e0e84b7e6990321140e0fe9795e1c64bf45c2bf5fb2
7
- data.tar.gz: e169300a7f5e7b4c3755107895522275eaa3b71d17495cd14cac3cd5227d6b1f2529f8a27695f5301cbd7d92f20917c9e37dbd66be0babf49cdd98c3b3be3674
6
+ metadata.gz: 95f56b7a5d1b36565074904d49e5ff69b34fb57bed690a7bec5d269749c6813e094b4e02b397517523edd90e0714710376f489338788838fad775f3c724659db
7
+ data.tar.gz: 380096a9132eb857a4a321260593640eec0d6a5b08f661e97a1ba2396619eb5984c1aca488e364930dff737ff397b07865597b9ed321c7a93101c151a8d2566f
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ class LabelsController < ApplicationController
5
+ def print_order_label
6
+ order_id = params.require(:order_id)
7
+
8
+ label = LabellingService::OrderLabel.new(order_id)
9
+ send_data(label.print, type: 'application/label; charset=utf-8',
10
+ stream: false,
11
+ filename: "#{SecureRandom.hex(24)}.lbl",
12
+ disposition: 'inline')
13
+ end
14
+ end
15
+ 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
@@ -29,9 +29,16 @@ module Lab
29
29
  # given block until the queue is empty or connection is terminated
30
30
  # by calling method +choke+.
31
31
  def consume_orders(from: 0, limit: 30)
32
+ last_seq = { value: 0 }
33
+
32
34
  bum.binge_changes(since: from, limit: limit, include_docs: true) do |change|
35
+ next unless change['doc']['type']&.casecmp?('Order')
36
+
33
37
  yield OrderDTO.new(change['doc']), self
38
+ last_seq[:value] = self.last_seq
34
39
  end
40
+
41
+ last_seq[:value]
35
42
  end
36
43
 
37
44
  def create_order(order)
@@ -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,206 @@
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 '../orders_service'
33
+ require_relative '../results_service'
34
+ require_relative '../tests_service'
35
+ require_relative '../../../serializers/lab/lab_order_serializer'
36
+ require_relative '../../../serializers/lab/result_serializer'
37
+ require_relative '../../../serializers/lab/test_serializer'
38
+
39
+ require_relative 'order_dto'
40
+ require_relative 'utils'
41
+
42
+ module Lab
43
+ module Lims
44
+ module Migrator
45
+ class MigratorApi < Api
46
+ MAX_THREADS = 6
47
+
48
+ attr_reader :rejections
49
+
50
+ def consume_orders(from: nil, limit: 50_000)
51
+ Parallel.each(read_orders(from, limit),
52
+ in_processes: MAX_THREADS,
53
+ finish: order_pmap_post_processor(from)) do |row|
54
+ next unless row['doc']['type']&.casecmp?('Order')
55
+
56
+ User.current = Migrator.lab_user
57
+ yield OrderDTO.new(row['doc']), OpenStruct.new(last_seq: from)
58
+ end
59
+ end
60
+
61
+ def last_seq
62
+ return 0 unless File.exist?(last_seq_path)
63
+
64
+ File.open(last_seq_path, File::RDONLY) do |file|
65
+ last_seq = file.read&.strip
66
+ return last_seq.blank? ? nil : last_seq&.to_i
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def last_seq_path
73
+ Rails.root.join('log/lims/migration-last-id.dat')
74
+ end
75
+
76
+ def order_pmap_post_processor(last_seq)
77
+ lambda do |item, index, result|
78
+ save_last_seq(last_seq + index)
79
+ status, reason = result
80
+ next unless status == :rejected
81
+
82
+ (@rejections ||= []) << OpenStruct.new(order: OrderDTO.new(item['doc']), reason: reason)
83
+ end
84
+ end
85
+
86
+ def save_last_seq(last_seq)
87
+ return unless last_seq
88
+
89
+ File.open(last_seq_path, File::WRONLY | File::CREAT, 0o644) do |file|
90
+ Rails.logger.debug("Process ##{Parallel.worker_number}: Saving last seq: #{last_seq}")
91
+ file.flock(File::LOCK_EX)
92
+ file.write(last_seq.to_s)
93
+ file.flush
94
+ end
95
+ end
96
+
97
+ def save_rejection(order_dto, reason); end
98
+
99
+ def read_orders(from, batch_size)
100
+ Enumerator.new do |enum|
101
+ loop do
102
+ start_key_param = from ? "&skip=#{from}" : ''
103
+ url = "_all_docs?include_docs=true&limit=#{batch_size}#{start_key_param}"
104
+
105
+ Rails.logger.debug("#{MigratorApi}: Pulling orders from LIMS CouchDB: #{url}")
106
+ response = bum.couch_rest :get, url
107
+
108
+ from ||= 0
109
+
110
+ break from if response['rows'].empty?
111
+
112
+ response['rows'].each do |row|
113
+ enum.yield(row)
114
+ end
115
+
116
+ from += response['rows'].size
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ class MigrationWorker < Worker
123
+ protected
124
+
125
+ def last_seq
126
+ lims_api.last_seq
127
+ end
128
+
129
+ def update_last_seq(_last_seq); end
130
+ end
131
+
132
+ def self.lab_user
133
+ user = User.find_by_username('lab_daemon')
134
+ return user if user
135
+
136
+ god_user = User.first
137
+
138
+ person = Person.create!(creator: god_user.user_id)
139
+ PersonName.create!(person: person, given_name: 'Lab', family_name: 'Daemon', creator: god_user.user_id)
140
+
141
+ User.create!(username: 'lab_daemon', person: person, creator: god_user.user_id)
142
+ end
143
+
144
+ def self.save_csv(filename, rows:, headers: nil)
145
+ CSV.open(filename, File::WRONLY | File::CREAT) do |csv|
146
+ csv << headers if headers
147
+ rows.each { |row| csv << row }
148
+ end
149
+ end
150
+
151
+ MIGRATION_REJECTIONS_CSV_PATH = Rails.root.join('log/lims/migration-rejections.csv')
152
+
153
+ def self.export_rejections(rejections)
154
+ headers = ['Accession number', 'NHID', 'First name', 'Last name', 'Reason']
155
+ rows = (rejections || []).map do |rejection|
156
+ [
157
+ rejection.order[:tracking_number],
158
+ rejection.order[:patient][:id],
159
+ rejection.order[:patient][:first_name],
160
+ rejection.order[:patient][:last_name],
161
+ rejection.reason
162
+ ]
163
+ end
164
+
165
+ save_csv(MIGRATION_REJECTIONS_CSV_PATH, headers: headers, rows: rows)
166
+ end
167
+
168
+ MIGRATION_FAILURES_CSV_PATH = Rails.root.join('log/lims/migration-failures.csv')
169
+
170
+ def self.export_failures
171
+ headers = ['Accession number', 'NHID', 'Reason', 'Difference']
172
+ rows = Lab::LimsFailedImport.all.map do |failure|
173
+ [
174
+ failure.tracking_number,
175
+ failure.patient_nhid,
176
+ failure.reason,
177
+ failure.diff
178
+ ]
179
+ end
180
+
181
+ save_csv(MIGRATION_FAILURES_CSV_PATH, headers: headers, rows: rows)
182
+ end
183
+
184
+ MIGRATION_LOG_PATH = Rails.root.join('log/lims/migration.log')
185
+
186
+ def self.start_migration
187
+ log_dir = Rails.root.join('log/lims')
188
+ Dir.mkdir(log_dir) unless File.exist?(log_dir)
189
+
190
+ logger = LoggerMultiplexor.new(Logger.new($stdout), MIGRATION_LOG_PATH)
191
+ logger.level = :debug
192
+ Rails.logger = logger
193
+ ActiveRecord::Base.logger = logger
194
+ # CouchBum.logger = logger
195
+
196
+ api = MigratorApi.new
197
+ worker = MigrationWorker.new(api)
198
+
199
+ worker.pull_orders
200
+ ensure
201
+ api && export_rejections(api.rejections)
202
+ export_failures
203
+ end
204
+ end
205
+ end
206
+ end
@@ -1,153 +1,62 @@
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']
34
+
35
+ if %w[specimen_not_collected not_assigned].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
147
56
  # TODO: Extend requesting clinician to an obs tree having extra parameters
148
57
  # 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 || ''
58
+ first_name = self['who_order_test']['first_name'] || ''
59
+ last_name = self['who_order_test']['last_name'] || ''
151
60
 
152
61
  if first_name.blank? && last_name.blank?
153
62
  logger.warn('Missing requesting clinician name')
@@ -157,8 +66,8 @@ module Lab
157
66
  "#{first_name} #{last_name}"
158
67
  end
159
68
 
160
- def start_date(lims_order_date_created)
161
- lims_order_date_created.to_datetime
69
+ def start_date
70
+ Utils.parse_date(self['date_created'])
162
71
  end
163
72
 
164
73
  # Parses a LIMS facility name
@@ -168,9 +77,19 @@ module Lab
168
77
  lims_target_lab
169
78
  end
170
79
 
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
80
+ # Translates a LIMS sample priority to a concept_id
81
+ def reason_for_test
82
+ return unknown_concept.concept_id unless self['sample_priority']
83
+
84
+ ConceptName.find_by_name!(self['sample_priority']).concept_id
85
+ end
86
+
87
+ def lab_program
88
+ Program.find_by_name!(Lab::Metadata::LAB_PROGRAM_NAME)
89
+ end
90
+
91
+ def unknown_concept
92
+ ConceptName.find_by_name!('Unknown')
174
93
  end
175
94
  end
176
95
  end