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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ ##
6
+ # Serializes a LabOrder into a LIMS OrderDTO.
7
+ module OrderSerializer
8
+ class << self
9
+ include Utils
10
+
11
+ def serialize_order(order)
12
+ serialized_order = structify(Lab::LabOrderSerializer.serialize_order(order))
13
+
14
+ OrderDTO.new(
15
+ tracking_number: serialized_order.accession_number,
16
+ sending_facility: current_facility_name,
17
+ receiving_facility: serialized_order.target_lab,
18
+ tests: serialized_order.tests.collect(&:name),
19
+ patient: format_patient(serialized_order.patient_id),
20
+ order_location: format_order_location(serialized_order.encounter_id),
21
+ sample_type: format_sample_type(serialized_order.specimen.name),
22
+ sample_status: format_sample_status(serialized_order.specimen.name),
23
+ districy: current_district, # yes districy [sic]...
24
+ priority: serialized_order.reason_for_test.name,
25
+ date_created: serialized_order.order_date,
26
+ test_results: format_test_results(serialized_order),
27
+ type: 'Order'
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def format_order_location(encounter_id)
34
+ location_id = Encounter.select(:location_id).where(encounter_id: encounter_id)
35
+ location = Location.select(:name)
36
+ .where(location_id: location_id)
37
+ .first
38
+
39
+ location&.name
40
+ end
41
+
42
+ # Format patient into a structure that LIMS expects
43
+ def format_patient(patient_id)
44
+ person = Person.find(patient_id)
45
+ name = PersonName.find_by_person_id(patient_id)
46
+ national_id = PatientIdentifier.joins(:type)
47
+ .merge(PatientIdentifierType.where(name: 'National ID'))
48
+ .where(patient_id: patient_id)
49
+ .first
50
+ phone_number = PersonAttribute.joins(:type)
51
+ .merge(PersonAttributeType.where(name: 'Cell phone Number'))
52
+ .where(person_id: patient_id)
53
+ .first
54
+
55
+ {
56
+ first_name: name&.given_name,
57
+ last_name: name&.family_name,
58
+ id: national_id&.identifier,
59
+ phone_number: phone_number.value,
60
+ gender: person.gender,
61
+ email: nil
62
+ }
63
+ end
64
+
65
+ def format_sample_type(name)
66
+ name.casecmp?('Unknown') ? 'not_specified' : name
67
+ end
68
+
69
+ def format_sample_status(name)
70
+ name.casecmp?('Unknown') ? 'specimen_not_collected' : 'specimen_collected'
71
+ end
72
+
73
+ def format_test_results(order)
74
+ order.tests.each_with_object({}) do |test, results|
75
+ results[test.name] = {
76
+ results: test.result.each_with_object({}) do |measure, measures|
77
+ measures[measure.indicator.name] = { result_value: "#{measure.value_modifier}#{measure.value}" }
78
+ end,
79
+ result_date: test.result.first&.date,
80
+ result_entered_by: {}
81
+ }
82
+ end
83
+ end
84
+
85
+ def current_health_center
86
+ health_center = Location.current_health_center
87
+ raise 'Current health center not set' unless health_center
88
+
89
+ health_center
90
+ end
91
+
92
+ def current_district
93
+ district = current_health_center.city_village\
94
+ || current_health_center.parent&.name\
95
+ || GlobalProperty.find_by_property('current_health_center_district')&.property_value
96
+
97
+ return district if district
98
+
99
+ GlobalProperty.create(property: 'current_health_center_district',
100
+ property_value: Config.application['district'],
101
+ uuid: SecureRandom.uuid)
102
+
103
+ Config.application['district']
104
+ end
105
+
106
+ def current_facility_name
107
+ current_health_center.name
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ ##
6
+ # Various helper methods for modules in the Lims namespaces...
7
+ module Utils
8
+ def logger
9
+ Rails.logger
10
+ end
11
+
12
+ def structify(object)
13
+ if object.is_a?(Hash)
14
+ object.each_with_object(OpenStruct.new) do |kv_pair, struct|
15
+ key, value = kv_pair
16
+
17
+ struct[key] = structify(value)
18
+ end
19
+ elsif object.respond_to?(:map)
20
+ object.map { |item| structify(item) }
21
+ else
22
+ object
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './utils'
4
+
5
+ module Lab
6
+ module Lims
7
+ ##
8
+ # Pull/Push orders from/to the LIMS queue (Oops meant CouchDB).
9
+ class Worker
10
+ def initialize(lims_api)
11
+ @lims_api = lims_api
12
+ end
13
+
14
+ include Utils
15
+
16
+ attr_reader :lims_api
17
+
18
+ def push_orders(batch_size: 100)
19
+ loop do
20
+ logger.info('Fetching new orders...')
21
+ orders = LabOrder.where.not(order_id: LimsOrderMapping.all.select(:order_id))
22
+ .limit(batch_size)
23
+
24
+ if orders.empty?
25
+ logger.info('No new orders available; exiting...')
26
+ break
27
+ end
28
+
29
+ orders.each { |order| push_order(order) }
30
+ end
31
+ end
32
+
33
+ def push_order_by_id(order_id)
34
+ order = LabOrder.find(order_id)
35
+ push_order(order)
36
+ end
37
+
38
+ ##
39
+ # Pushes given order to LIMS queue
40
+ def push_order(order)
41
+ logger.info("Pushing order ##{order.order_id}")
42
+
43
+ order_dto = OrderDTO.from_order(order)
44
+ mapping = LimsOrderMapping.find_by(order_id: order.order_id)
45
+
46
+ if mapping
47
+ lims_api.update_order(mapping.lims_id, order_dto)
48
+ mapping.update(pushed_at: Time.now)
49
+ else
50
+ order_dto = lims_api.create_order(order_dto)
51
+ LimsOrderMapping.create!(order: order, lims_id: order_dto['_id'], pushed_at: Time.now)
52
+ end
53
+
54
+ order_dto
55
+ end
56
+
57
+ ##
58
+ # Pulls orders from the LIMS queue and writes them to the local database
59
+ def pull_orders
60
+ lims_api.consume_orders(from: last_seq) do |order_dto, context|
61
+ logger.debug(`Retrieved order ##{order[:tracking_number]}`)
62
+
63
+ patient = find_patient_by_nhid(order_dto[:patient][:id])
64
+
65
+ unless patient
66
+ logger.debug(`Discarding order: Non local patient ##{order_dto[:patient][:id]} on order ##{order[:tracking_number]}`)
67
+ break
68
+ end
69
+
70
+ save_order(patient, order_dto)
71
+ update_last_seq(context.last_seq)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def find_patient_by_nhid(nhid)
78
+ national_id_type = PatientIdentifierType.where(name: 'National id')
79
+ identifier = PatientIdentifier.where(type: national_id_type, identifier: nhid)
80
+ patients = Patient.joins(:identifiers).merge(identifier).group(:patient_id).all
81
+
82
+ raise "Duplicate National Health ID: #{nhid}" if patients.size > 1
83
+
84
+ patients.first
85
+ end
86
+
87
+ def save_order(patient, order_dto)
88
+ mapping = LimsOrderMapping.find_by(couch_id: order_dto[:_id])
89
+
90
+ if mapping
91
+ update_order(patient, mapping.order_id, order_dto)
92
+ mapping.update(pulled_at: Time.now)
93
+ else
94
+ order = create_order(patient, order_dto)
95
+ LimsOrderMapping.create!(lims_id: order_dto[:_id], order: order, pulled_at: Time.now)
96
+ end
97
+
98
+ order
99
+ end
100
+
101
+ def create_order(patient, order_dto)
102
+ order = OrdersService.order_test(patient, order_dto.to_order_service_params)
103
+ update_results(order, order_dto.test_results)
104
+
105
+ order
106
+ end
107
+
108
+ def update_order(_patient, order_id, order_dto)
109
+ order = OrdersService.update_order(order_id, order_dto.to_order_service_params)
110
+ update_results(order, order_dto.test_results)
111
+
112
+ order
113
+ end
114
+
115
+ def update_results(_order, _lims_results)
116
+ # TODO: Implement me
117
+ raise 'Not implemented error'
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Metadata
5
+ # Concepts
6
+ REASON_FOR_TEST_CONCEPT_NAME = 'Reason for test'
7
+ REQUESTING_CLINICIAN_CONCEPT_NAME = 'Person making request'
8
+ SPECIMEN_TYPE_CONCEPT_NAME = 'Specimen type'
9
+ TARGET_LAB_CONCEPT_NAME = 'Lab'
10
+ TEST_RESULT_CONCEPT_NAME = 'Lab test result'
11
+ TEST_RESULT_INDICATOR_CONCEPT_NAME = 'Lab test result indicator'
12
+ TEST_TYPE_CONCEPT_NAME = 'Test type'
13
+
14
+ # Encounter
15
+ ENCOUNTER_TYPE_NAME = 'Lab'
16
+
17
+ # Order types
18
+ ORDER_TYPE_NAME = 'Lab'
19
+
20
+ # Programs
21
+ LAB_PROGRAM_NAME = 'Lab Program'
22
+ end
23
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ # Search Lab orders.
5
+ module OrdersSearchService
6
+ class << self
7
+ def find_orders(filters)
8
+ date = filters.delete(:date)
9
+ status = filters.delete(:status)
10
+
11
+ orders = Lab::LabOrder.prefetch_relationships
12
+ .where(filters)
13
+ .order(start_date: :desc)
14
+
15
+ orders = filter_orders_by_date(orders, date) if date
16
+ orders = filter_orders_by_status(orders, status) if status
17
+
18
+ orders.map { |order| Lab::LabOrderSerializer.serialize_order(order) }
19
+ end
20
+
21
+ def filter_orders_by_date(orders, date)
22
+ orders.where('start_date < DATE(?)', date.to_date + 1.day)
23
+ end
24
+
25
+ def filter_orders_by_status(orders, status)
26
+ case status.downcase
27
+ when 'ordered' then orders.where(concept_id: unknown_concept_id)
28
+ when 'drawn' then orders.where.not(concept_id: unknown_concept_id)
29
+ end
30
+ end
31
+
32
+ def unknown_concept_id
33
+ ConceptName.find_by_name!('Unknown').concept_id
34
+ end
35
+
36
+ def filter_orders_by_status(orders, status)
37
+ case status.downcase
38
+ when 'ordered' then orders.where(concept_id: unknown_concept_id)
39
+ when 'drawn' then orders.where.not(concept_id: unknown_concept_id)
40
+ end
41
+ end
42
+
43
+ def unknown_concept_id
44
+ ConceptName.find_by_name!('Unknown').concept_id
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ ##
5
+ # Manage lab orders.
6
+ #
7
+ # Lab orders are just ordinary openmrs orders with extra metadata that
8
+ # separates them from other orders. Lab orders have an order type of 'Lab'
9
+ # with the order's test type as the order's concept. The order's start
10
+ # date is the day the order is made. Additional information pertaining to
11
+ # the order is stored as observations that point to the order. The
12
+ # specimen types, requesting clinician, target lab, and reason for test
13
+ # are saved as observations to the order. Refer to method #order_test for
14
+ # more information.
15
+ module OrdersService
16
+ class << self
17
+ ##
18
+ # Create a lab order.
19
+ #
20
+ # Parameters schema:
21
+ #
22
+ # {
23
+ # encounter_id: {
24
+ # type: :integer,
25
+ # required: :false,
26
+ # description: 'Attach order to this if program_id and patient_id are not provided'
27
+ # },
28
+ # program_id: { type: :integer, required: false },
29
+ # patient_id: { type: :integer, required: false }
30
+ # specimen_type_id: { type: :object, properties: { concept_id: :integer }, required: %i[concept_id] },
31
+ # test_type_ids: {
32
+ # type: :array,
33
+ # items: {
34
+ # type: :object,
35
+ # properties: { concept_id: :integer },
36
+ # required: %i[concept_id]
37
+ # }
38
+ # },
39
+ # start_date: { type: :datetime }
40
+ # accession_number: { type: :string }
41
+ # target_lab: { type: :string },
42
+ # reason_for_test_id: { type: :integer },
43
+ # requesting_clinician: { type: :string }
44
+ # }
45
+ #
46
+ # encounter_id: is an ID of the encounter the lab order is to be created under
47
+ # test_type_id: is a concept_id of the name of test being ordered
48
+ # specimen_type_id: is a list of IDs for the specimens to be tested (can be ommited)
49
+ # target_lab: is the name of the lab where test will be carried out
50
+ # reason_for_test_id: is a concept_id for a (standard) reason of why the test is being carried out
51
+ # requesting_clinician: Name of the clinician requesting the test (defaults to current user)
52
+ def order_test(order_params)
53
+ Order.transaction do
54
+ encounter = find_encounter(order_params)
55
+ order = create_order(encounter, order_params)
56
+
57
+ Lab::TestsService.create_tests(order, order_params[:date], order_params[:tests])
58
+
59
+ Lab::LabOrderSerializer.serialize_order(
60
+ order, requesting_clinician: add_requesting_clinician(order, order_params),
61
+ reason_for_test: add_reason_for_test(order, order_params),
62
+ target_lab: add_target_lab(order, order_params)
63
+ )
64
+ end
65
+ end
66
+
67
+ def update_order(order_id, params)
68
+ specimen_id = params.dig(:specimen, :concept_id)
69
+ unless specimen_id
70
+ raise ::InvalidParameterError, 'Specimen concept_id is required'
71
+ end
72
+
73
+ order = Lab::LabOrder.find(order_id)
74
+ unless order.concept_id == unknown_concept_id
75
+ raise ::UnprocessableEntityError
76
+ end
77
+
78
+ order.update!(concept_id: specimen_id)
79
+ Lab::LabOrderSerializer.serialize_order(order)
80
+ end
81
+
82
+ def void_order(order_id, reason)
83
+ order = Lab::LabOrder.includes(%i[requesting_clinician reason_for_test target_lab], tests: [:result])
84
+ .find(order_id)
85
+
86
+ order.requesting_clinician&.void(reason)
87
+ order.reason_for_test&.void(reason)
88
+ order.target_lab&.void(reason)
89
+
90
+ order.tests.each do |test|
91
+ test.result&.void(reason)
92
+ test.void(reason)
93
+ end
94
+
95
+ order.void(reason)
96
+ end
97
+
98
+ private
99
+
100
+ ##
101
+ # Extract an encounter from the given parameters.
102
+ #
103
+ # Uses an encounter_id to retrieve an encounter if provided else
104
+ # a 'Lab' encounter is created using the provided program_id and
105
+ # patient_id.
106
+ def find_encounter(order_params)
107
+ if order_params[:encounter_id]
108
+ return Encounter.find(order_params[:encounter_id])
109
+ end
110
+
111
+ unless order_params[:patient_id]
112
+ raise InvalidParameterError, 'encounter_id or patient_id required'
113
+ end
114
+
115
+ program_id = order_params[:program_id] || Program.find_by_name!(Lab::Metadata::LAB_PROGRAM_NAME).program_id
116
+
117
+ Encounter.create!(
118
+ patient_id: order_params[:patient_id],
119
+ program_id: program_id,
120
+ type: EncounterType.find_by_name!(Lab::Metadata::ENCOUNTER_TYPE_NAME),
121
+ encounter_datetime: order_params[:date] || Date.today,
122
+ provider_id: order_params[:provider_id] || User.current&.user_id
123
+ )
124
+ end
125
+
126
+ def create_order(encounter, params)
127
+ Lab::LabOrder.create!(
128
+ order_type: OrderType.find_by_name!(Lab::Metadata::ORDER_TYPE_NAME),
129
+ concept_id: params.dig(:specimen, :concept_id) || unknown_concept_id,
130
+ encounter_id: encounter.encounter_id,
131
+ patient_id: encounter.patient_id,
132
+ start_date: params[:date]&.to_date || Date.today,
133
+ auto_expire_date: params[:end_date],
134
+ accession_number: params[:accession_number] || next_accession_number,
135
+ orderer: User.current&.user_id
136
+ )
137
+ end
138
+
139
+ ##
140
+ # Attach the requesting clinician to an order
141
+ def add_requesting_clinician(order, params)
142
+ create_order_observation(
143
+ order,
144
+ Lab::Metadata::REQUESTING_CLINICIAN_CONCEPT_NAME,
145
+ params[:date],
146
+ value_text: params['requesting_clinician']
147
+ )
148
+ end
149
+
150
+ ##
151
+ # Attach a reason for the order/test
152
+ #
153
+ # Examples of reasons include: Routine, Targeted, Confirmatory, Repeat, or Stat.
154
+ def add_reason_for_test(order, params)
155
+ create_order_observation(
156
+ order,
157
+ Lab::Metadata::REASON_FOR_TEST_CONCEPT_NAME,
158
+ params[:date],
159
+ value_coded: params['reason_for_test_id']
160
+ )
161
+ end
162
+
163
+ ##
164
+ # Attach the lab where the test is going to get carried out.
165
+ def add_target_lab(order, params)
166
+ create_order_observation(
167
+ order,
168
+ Lab::Metadata::TARGET_LAB_CONCEPT_NAME,
169
+ params[:date],
170
+ value_text: params['target_lab']
171
+ )
172
+ end
173
+
174
+ def create_order_observation(order, concept_name, date, **values)
175
+ Observation.create!(
176
+ order: order,
177
+ encounter_id: order.encounter_id,
178
+ person_id: order.patient_id,
179
+ concept_id: ConceptName.find_by_name!(concept_name).concept_id,
180
+ obs_datetime: date&.to_time || Time.now,
181
+ **values
182
+ )
183
+ end
184
+
185
+ def next_accession_number(date = nil)
186
+ Lab::AccessionNumberService.next_accession_number(date)
187
+ end
188
+
189
+ def unknown_concept_id
190
+ ConceptName.find_by_name!('Unknown').concept_id
191
+ end
192
+ end
193
+ end
194
+ end