his_emr_api_lab 0.0.2

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