his_emr_api_lab 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +52 -0
  4. data/Rakefile +32 -0
  5. data/app/controllers/lab/application_controller.rb +6 -0
  6. data/app/controllers/lab/orders_controller.rb +34 -0
  7. data/app/controllers/lab/reasons_for_test_controller.rb +9 -0
  8. data/app/controllers/lab/results_controller.rb +19 -0
  9. data/app/controllers/lab/specimen_types_controller.rb +15 -0
  10. data/app/controllers/lab/test_result_indicators_controller.rb +9 -0
  11. data/app/controllers/lab/test_types_controller.rb +15 -0
  12. data/app/controllers/lab/tests_controller.rb +25 -0
  13. data/app/jobs/lab/application_job.rb +4 -0
  14. data/app/mailers/lab/application_mailer.rb +6 -0
  15. data/app/models/lab/application_record.rb +5 -0
  16. data/app/models/lab/lab_accession_number_counter.rb +13 -0
  17. data/app/models/lab/lab_encounter.rb +7 -0
  18. data/app/models/lab/lab_order.rb +47 -0
  19. data/app/models/lab/lab_result.rb +21 -0
  20. data/app/models/lab/lab_test.rb +14 -0
  21. data/app/models/lab/lims_failed_import.rb +4 -0
  22. data/app/models/lab/lims_order_mapping.rb +10 -0
  23. data/app/serializers/lab/lab_order_serializer.rb +49 -0
  24. data/app/serializers/lab/result_serializer.rb +36 -0
  25. data/app/serializers/lab/test_serializer.rb +29 -0
  26. data/app/services/lab/accession_number_service.rb +77 -0
  27. data/app/services/lab/concepts_service.rb +82 -0
  28. data/app/services/lab/lims/api.rb +46 -0
  29. data/app/services/lab/lims/config.rb +56 -0
  30. data/app/services/lab/lims/order_dto.rb +177 -0
  31. data/app/services/lab/lims/order_serializer.rb +112 -0
  32. data/app/services/lab/lims/utils.rb +27 -0
  33. data/app/services/lab/lims/worker.rb +121 -0
  34. data/app/services/lab/metadata.rb +23 -0
  35. data/app/services/lab/orders_search_service.rb +48 -0
  36. data/app/services/lab/orders_service.rb +194 -0
  37. data/app/services/lab/results_service.rb +92 -0
  38. data/app/services/lab/tests_service.rb +93 -0
  39. data/config/routes.rb +15 -0
  40. data/db/migrate/20210126092910_create_lab_lab_accession_number_counters.rb +12 -0
  41. data/db/migrate/20210310115457_create_lab_lims_order_mappings.rb +15 -0
  42. data/db/migrate/20210323080140_change_lims_id_to_string_in_lims_order_mapping.rb +15 -0
  43. data/db/migrate/20210326195504_add_order_revision_to_lims_order_mapping.rb +5 -0
  44. data/db/migrate/20210407071728_create_lab_lims_failed_imports.rb +19 -0
  45. data/lib/couch_bum/couch_bum.rb +77 -0
  46. data/lib/generators/lab/install/USAGE +9 -0
  47. data/lib/generators/lab/install/install_generator.rb +19 -0
  48. data/lib/generators/lab/install/templates/rswag-ui-lab.rb +5 -0
  49. data/lib/generators/lab/install/templates/start_worker.rb +32 -0
  50. data/lib/generators/lab/install/templates/swagger.yaml +682 -0
  51. data/lib/his_emr_api_lab.rb +5 -0
  52. data/lib/lab/engine.rb +15 -0
  53. data/lib/lab/version.rb +5 -0
  54. data/lib/logger_multiplexor.rb +32 -0
  55. data/lib/tasks/lab_tasks.rake +25 -0
  56. data/lib/tasks/loaders/data/reasons-for-test.csv +6 -0
  57. data/lib/tasks/loaders/data/test-measures.csv +224 -0
  58. data/lib/tasks/loaders/data/tests.csv +142 -0
  59. data/lib/tasks/loaders/loader_mixin.rb +53 -0
  60. data/lib/tasks/loaders/metadata_loader.rb +26 -0
  61. data/lib/tasks/loaders/reasons_for_test_loader.rb +23 -0
  62. data/lib/tasks/loaders/specimens_loader.rb +65 -0
  63. data/lib/tasks/loaders/test_result_indicators_loader.rb +54 -0
  64. metadata +296 -0
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ ##
5
+ # Serialize a Lab order result
6
+ module ResultSerializer
7
+ def self.serialize(result)
8
+ result.children.map do |measure|
9
+ value, value_type = read_value(measure)
10
+ concept_name = ConceptName.find_by_concept_id(measure.concept_id)
11
+
12
+ {
13
+ id: measure.obs_id,
14
+ indicator: {
15
+ concept_id: concept_name&.concept_id,
16
+ name: concept_name&.name
17
+ },
18
+ date: measure.obs_datetime,
19
+ value: value,
20
+ value_type: value_type,
21
+ value_modifier: measure.value_modifier
22
+ }
23
+ end
24
+ end
25
+
26
+ def self.read_value(measure)
27
+ %w[value_numeric value_coded value_boolean value_text].each do |field|
28
+ value = measure.send(field)
29
+
30
+ return [value, field.split('_')[1]] if value
31
+ end
32
+
33
+ [nil, 'unknown']
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module TestSerializer
5
+ def self.serialize(test, order: nil, result: nil)
6
+ order ||= test.order
7
+ result ||= test.result
8
+
9
+ {
10
+ id: test.obs_id,
11
+ concept_id: test.value_coded,
12
+ name: ConceptName.find_by_concept_id(test.value_coded)&.name,
13
+ order: {
14
+ id: order.order_id,
15
+ concept_id: order.concept_id,
16
+ name: ConceptName.find_by_concept_id(order.concept_id)&.name,
17
+ accession_number: order.accession_number
18
+ },
19
+ result: if result
20
+ {
21
+ id: result.obs_id,
22
+ modifier: result.value_modifier,
23
+ value: result.value_text
24
+ }
25
+ end
26
+ }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ # Responsible for the generation of tracking numbers
5
+ module AccessionNumberService
6
+ class << self
7
+ # Returns the next accession number on the given date or today.
8
+ #
9
+ # Throws:
10
+ # RangeError - If date is greater than system date
11
+ def next_accession_number(date = nil)
12
+ date = validate_date(date || Date.today)
13
+ counter = find_counter(date)
14
+
15
+ counter.with_lock do
16
+ accession_number = format_accession_number(date, counter.value)
17
+ counter.value += 1
18
+ counter.save!
19
+
20
+ return accession_number
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def find_counter(date)
27
+ counter = Lab::LabAccessionNumberCounter.find_by(date: date)
28
+ return counter if counter
29
+
30
+ Lab::LabAccessionNumberCounter.create(date: date, value: 1)
31
+ end
32
+
33
+ # Checks if date does not exceed system date
34
+ def validate_date(date)
35
+ return date unless date > Date.today
36
+
37
+ raise RangeError, "Specified date exceeds system date: #{date} > #{Date.today}"
38
+ end
39
+
40
+ def format_accession_number(date, counter)
41
+ year = format_year(date.year)
42
+ month = format_month(date.month)
43
+ day = format_day(date.day)
44
+
45
+ "X#{site_code}#{year}#{month}#{day}#{counter}"
46
+ end
47
+
48
+ def format_year(year)
49
+ (year % 100).to_s.rjust(2, '0')
50
+ end
51
+
52
+ # It's base 32 that uses letters for values 10+ but the letters
53
+ # are ordered in a way that seems rather arbitrary
54
+ # (see #get_day in https://github.com/HISMalawi/nlims_controller/blob/3c0faf1cb6572a11cb3b9bd1ea8444f457d01fd7/lib/tracking_number_service.rb#L58)
55
+ DAY_NUMBERING_SYSTEM = %w[1 2 3 4 5 6 7 8 9 A B C E F G H Y J K Z M N O P Q R S T V W X].freeze
56
+
57
+ def format_day(day)
58
+ DAY_NUMBERING_SYSTEM[day - 1]
59
+ end
60
+
61
+ def format_month(month)
62
+ # Months use a base 13 numbering system that's just a subset of the
63
+ # numbering system used for days
64
+ format_day(month)
65
+ end
66
+
67
+ def site_code
68
+ property = GlobalProperty.find_by(property: 'site_prefix')
69
+ value = property&.property_value&.strip
70
+
71
+ raise "Global property 'site_prefix' not set" unless value
72
+
73
+ value
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ # A read-only repository of sort for all lab-centric concepts.
5
+ module ConceptsService
6
+ def self.test_types(name: nil, specimen_type: nil)
7
+ test_types = ConceptSet.find_members_by_name(Lab::Metadata::TEST_TYPE_CONCEPT_NAME)
8
+ test_types = test_types.filter_members(name: name) if name
9
+
10
+ unless specimen_type
11
+ return test_types.joins('INNER JOIN concept_name ON concept_set.concept_id = concept_name.concept_id')
12
+ .select('concept_name.name, concept_name.concept_id')
13
+ .group('concept_name.concept_id')
14
+ end
15
+
16
+ # Filter out only those test types that have the specified specimen
17
+ # type.
18
+ specimen_types = ConceptSet.find_members_by_name(Lab::Metadata::SPECIMEN_TYPE_CONCEPT_NAME)
19
+ .filter_members(name: specimen_type)
20
+ .select(:concept_id)
21
+
22
+ concept_set = ConceptSet.where(
23
+ concept_id: specimen_types,
24
+ concept_set: test_types.select(:concept_id)
25
+ )
26
+
27
+ concept_set.joins('INNER JOIN concept_name ON concept_set.concept_set = concept_name.concept_id')
28
+ .select('concept_name.concept_id, concept_name.name')
29
+ .group('concept_name.concept_id')
30
+ end
31
+
32
+ def self.specimen_types(name: nil, test_type: nil)
33
+ specimen_types = ConceptSet.find_members_by_name(Lab::Metadata::SPECIMEN_TYPE_CONCEPT_NAME)
34
+ specimen_types = specimen_types.filter_members(name: name) if name
35
+
36
+ unless test_type
37
+ return specimen_types.select('concept_name.concept_id, concept_name.name')
38
+ .joins('INNER JOIN concept_name ON concept_name.concept_id = concept_set.concept_id')
39
+ .group('concept_name.concept_id')
40
+ end
41
+
42
+ # Retrieve only those specimen types that belong to concept
43
+ # set of the selected test_type
44
+ test_types = ConceptSet.find_members_by_name(Lab::Metadata::TEST_TYPE_CONCEPT_NAME)
45
+ .filter_members(name: test_type)
46
+ .select(:concept_id)
47
+
48
+ concept_set = ConceptSet.where(
49
+ concept_id: specimen_types.select(:concept_id),
50
+ concept_set: test_types
51
+ )
52
+
53
+ concept_set.select('concept_name.concept_id, concept_name.name')
54
+ .joins('INNER JOIN concept_name ON concept_name.concept_id = concept_set.concept_id')
55
+ .group('concept_name.concept_id')
56
+ end
57
+
58
+ def self.test_result_indicators(test_type_id)
59
+ # Verify that the specified test_type is indeed a test_type
60
+ test = ConceptSet.find_members_by_name(Lab::Metadata::TEST_TYPE_CONCEPT_NAME)
61
+ .where(concept_id: test_type_id)
62
+ .select(:concept_id)
63
+
64
+ # From the members above, filter out only those concepts that are result indicators
65
+ measures = ConceptSet.find_members_by_name(Lab::Metadata::TEST_RESULT_INDICATOR_CONCEPT_NAME)
66
+ .select(:concept_id)
67
+
68
+ ConceptSet.where(concept_set: measures, concept_id: test)
69
+ .joins('INNER JOIN concept_name AS measure ON measure.concept_id = concept_set.concept_set')
70
+ .select('measure.concept_id, measure.name')
71
+ .group('measure.concept_id')
72
+ .map { |concept| { name: concept.name, concept_id: concept.concept_id } }
73
+ end
74
+
75
+ def self.reasons_for_test
76
+ ConceptSet.find_members_by_name(Lab::Metadata::REASON_FOR_TEST_CONCEPT_NAME)
77
+ .joins('INNER JOIN concept_name ON concept_name.concept_id = concept_set.concept_id')
78
+ .select('concept_name.concept_id, concept_name.name')
79
+ .map { |concept| { name: concept.name, concept_id: concept.concept_id } }
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'couch_bum/couch_bum'
4
+
5
+ require_relative './config'
6
+
7
+ module Lab
8
+ module Lims
9
+ ##
10
+ # Talk to LIMS like a boss
11
+ class Api
12
+ attr_reader :bum
13
+
14
+ def initialize(config: nil)
15
+ config ||= Config.couchdb
16
+
17
+ @bum = CouchBum.new(protocol: config['protocol'],
18
+ host: config['host'],
19
+ port: config['port'],
20
+ database: "#{config['prefix']}_order_#{config['suffix']}",
21
+ username: config['username'],
22
+ password: config['password'])
23
+ end
24
+
25
+ ##
26
+ # Consume orders from the LIMS queue.
27
+ #
28
+ # Retrieves orders from the LIMS queue and passes each order to
29
+ # given block until the queue is empty or connection is terminated
30
+ # by calling method +choke+.
31
+ def consume_orders(from: 0, limit: 30)
32
+ bum.binge_changes(since: from, limit: limit, include_docs: true) do |change|
33
+ yield OrderDTO.new(change['doc']), self
34
+ end
35
+ end
36
+
37
+ def create_order(order)
38
+ bum.couch_rest :post, '/', order
39
+ end
40
+
41
+ def update_order(id, order)
42
+ bum.couch_rest :put, "/#{id}", order
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ ##
6
+ # Load LIMS' configuration files
7
+ module Config
8
+ class ConfigNotFound < RuntimeError; end
9
+
10
+ class << self
11
+ ##
12
+ # Returns LIMS' couchdb configuration file for the current environment (Rails.env)
13
+ def couchdb
14
+ config_path = begin
15
+ find_config_path('couchdb.yml')
16
+ rescue ConfigNotFound => e
17
+ Rails.logger.error("Failed to find default LIMS couchdb config: #{e.message}")
18
+ find_config_path('couchdb-lims.yml') # This can be placed in HIS-EMR-API/config
19
+ end
20
+
21
+ Rails.logger.debug("Using LIMS couchdb config: #{config_path}")
22
+
23
+ YAML.load_file(config_path)[Rails.env]
24
+ end
25
+
26
+ ##
27
+ # Returns LIMS' application.yml configuration file
28
+ def application
29
+ YAML.load_file(find_config_path('application.yml'))
30
+ end
31
+
32
+ private
33
+
34
+ ##
35
+ # Looks for a config file in various LIMS installation directories
36
+ #
37
+ # Returns: a path to a file found
38
+ def find_config_path(filename)
39
+ paths = [
40
+ "#{ENV['HOME']}/apps/nlims_controller/config/#{filename}",
41
+ "/var/www/nlims_controller/config/#{filename}",
42
+ Rails.root.parent.join("nlims_controller/config/#{filename}"),
43
+ Rails.root.join('config/lims-couch.yml')
44
+ ]
45
+
46
+ paths.each do |path|
47
+ Rails.logger.debug("Looking for LIMS couchdb config at: #{path}")
48
+ return path if File.exist?(path)
49
+ end
50
+
51
+ raise ConfigNotFound, "Could not find a configuration file, checked: #{paths.join(':')}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ ##
6
+ # LIMS' Data Transfer Object for orders
7
+ 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
106
+
107
+ ##
108
+ # Unpacks a LIMS order into an object that OrdersService can handle
109
+ def to_order_service_params(lims_order)
110
+ ActiveSupport::HashWithIndifferentAccess.new(
111
+ 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)
120
+ )
121
+ end
122
+
123
+ private
124
+
125
+ # 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')
129
+ end
130
+
131
+ concept = ConceptName.select(:concept_id).find_by_name(lims_specimen_name)
132
+ return concept.concept_id if concept
133
+
134
+ raise "Unknown specimen name: #{lims_specimen_name}"
135
+ end
136
+
137
+ # Translates a LIMS test type name to an OpenMRS concept_id
138
+ def test_type_id(lims_test_name)
139
+ concept = ConceptName.select(:concept_id).find_by_name(lims_test_name)
140
+ return concept.concept_id if concept
141
+
142
+ raise "Unknown test type: #{lims_test_name}"
143
+ end
144
+
145
+ # Extract requesting clinician name from LIMS
146
+ def requesting_clinician(lims_user)
147
+ # TODO: Extend requesting clinician to an obs tree having extra parameters
148
+ # 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 || ''
151
+
152
+ if first_name.blank? && last_name.blank?
153
+ logger.warn('Missing requesting clinician name')
154
+ return ''
155
+ end
156
+
157
+ "#{first_name} #{last_name}"
158
+ end
159
+
160
+ def start_date(lims_order_date_created)
161
+ lims_order_date_created.to_datetime
162
+ end
163
+
164
+ # Parses a LIMS facility name
165
+ def facility_name(lims_target_lab)
166
+ return 'Unknown' if lims_target_lab == 'not_assigned'
167
+
168
+ lims_target_lab
169
+ end
170
+
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
174
+ end
175
+ end
176
+ end
177
+ end