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