his_emr_api_lab 0.0.2 → 0.0.3

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: 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