his_emr_api_lab 1.1.22 → 1.1.23

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +71 -0
  4. data/Rakefile +32 -0
  5. data/app/controllers/lab/application_controller.rb +6 -0
  6. data/app/controllers/lab/labels_controller.rb +17 -0
  7. data/app/controllers/lab/orders_controller.rb +38 -0
  8. data/app/controllers/lab/reasons_for_test_controller.rb +9 -0
  9. data/app/controllers/lab/results_controller.rb +19 -0
  10. data/app/controllers/lab/specimen_types_controller.rb +15 -0
  11. data/app/controllers/lab/test_result_indicators_controller.rb +9 -0
  12. data/app/controllers/lab/test_types_controller.rb +15 -0
  13. data/app/controllers/lab/tests_controller.rb +26 -0
  14. data/app/jobs/lab/application_job.rb +4 -0
  15. data/app/jobs/lab/push_order_job.rb +12 -0
  16. data/app/jobs/lab/update_patient_orders_job.rb +32 -0
  17. data/app/jobs/lab/void_order_job.rb +17 -0
  18. data/app/mailers/lab/application_mailer.rb +6 -0
  19. data/app/models/lab/application_record.rb +5 -0
  20. data/app/models/lab/lab_accession_number_counter.rb +13 -0
  21. data/app/models/lab/lab_encounter.rb +7 -0
  22. data/app/models/lab/lab_order.rb +58 -0
  23. data/app/models/lab/lab_result.rb +31 -0
  24. data/app/models/lab/lab_test.rb +19 -0
  25. data/app/models/lab/lims_failed_import.rb +4 -0
  26. data/app/models/lab/lims_order_mapping.rb +10 -0
  27. data/app/serializers/lab/lab_order_serializer.rb +55 -0
  28. data/app/serializers/lab/result_serializer.rb +36 -0
  29. data/app/serializers/lab/test_serializer.rb +29 -0
  30. data/app/services/lab/accession_number_service.rb +77 -0
  31. data/app/services/lab/concepts_service.rb +82 -0
  32. data/app/services/lab/labelling_service/order_label.rb +106 -0
  33. data/app/services/lab/lims/api/blackhole_api.rb +21 -0
  34. data/app/services/lab/lims/api/couchdb_api.rb +53 -0
  35. data/app/services/lab/lims/api/mysql_api.rb +316 -0
  36. data/app/services/lab/lims/api/rest_api.rb +416 -0
  37. data/app/services/lab/lims/api/ws_api.rb +121 -0
  38. data/app/services/lab/lims/api_factory.rb +19 -0
  39. data/app/services/lab/lims/config.rb +100 -0
  40. data/app/services/lab/lims/exceptions.rb +11 -0
  41. data/app/services/lab/lims/migrator.rb +216 -0
  42. data/app/services/lab/lims/order_dto.rb +105 -0
  43. data/app/services/lab/lims/order_serializer.rb +244 -0
  44. data/app/services/lab/lims/pull_worker.rb +289 -0
  45. data/app/services/lab/lims/push_worker.rb +149 -0
  46. data/app/services/lab/lims/utils.rb +91 -0
  47. data/app/services/lab/lims/worker.rb +86 -0
  48. data/app/services/lab/metadata.rb +24 -0
  49. data/app/services/lab/orders_search_service.rb +66 -0
  50. data/app/services/lab/orders_service.rb +212 -0
  51. data/app/services/lab/results_service.rb +149 -0
  52. data/app/services/lab/tests_service.rb +93 -0
  53. data/config/routes.rb +17 -0
  54. data/db/migrate/20210126092910_create_lab_lab_accession_number_counters.rb +12 -0
  55. data/db/migrate/20210310115457_create_lab_lims_order_mappings.rb +15 -0
  56. data/db/migrate/20210323080140_change_lims_id_to_string_in_lims_order_mapping.rb +15 -0
  57. data/db/migrate/20210326195504_add_order_revision_to_lims_order_mapping.rb +5 -0
  58. data/db/migrate/20210407071728_create_lab_lims_failed_imports.rb +19 -0
  59. data/db/migrate/20210610095024_fix_numeric_results_value_type.rb +20 -0
  60. data/db/migrate/20210807111531_add_default_to_lims_order_mapping.rb +7 -0
  61. data/lib/auto12epl.rb +201 -0
  62. data/lib/couch_bum/couch_bum.rb +92 -0
  63. data/lib/generators/lab/install/USAGE +9 -0
  64. data/lib/generators/lab/install/install_generator.rb +19 -0
  65. data/lib/generators/lab/install/templates/rswag-ui-lab.rb +5 -0
  66. data/lib/generators/lab/install/templates/start_worker.rb +32 -0
  67. data/lib/generators/lab/install/templates/swagger.yaml +714 -0
  68. data/lib/his_emr_api_lab.rb +5 -0
  69. data/lib/lab/engine.rb +15 -0
  70. data/lib/lab/version.rb +5 -0
  71. data/lib/logger_multiplexor.rb +38 -0
  72. data/lib/tasks/lab_tasks.rake +25 -0
  73. data/lib/tasks/loaders/data/reasons-for-test.csv +7 -0
  74. data/lib/tasks/loaders/data/test-measures.csv +225 -0
  75. data/lib/tasks/loaders/data/tests.csv +161 -0
  76. data/lib/tasks/loaders/loader_mixin.rb +53 -0
  77. data/lib/tasks/loaders/metadata_loader.rb +26 -0
  78. data/lib/tasks/loaders/reasons_for_test_loader.rb +23 -0
  79. data/lib/tasks/loaders/specimens_loader.rb +65 -0
  80. data/lib/tasks/loaders/test_result_indicators_loader.rb +54 -0
  81. metadata +81 -2
@@ -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,106 @@
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
+ tests,
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
+ short_concept_name(order.reason_for_test.value_coded) || '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
+ drawer_id = User.find(order.discontinued_by || order.creator).person_id
64
+ draw_date = (order.discontinued_date || order.start_date).strftime('%d/%^b/%Y %H:%M:%S')
65
+
66
+ name = PersonName.find_by_person_id(drawer_id)
67
+ return "#{name.given_name} #{name.family_name} #{draw_date}" if name
68
+
69
+ user = User.find_by_user_id(drawer_id)
70
+ user ? "#{user.username} #{draw_date}" : 'N/A'
71
+ end
72
+
73
+ def specimen
74
+ return 'N/A' if order.concept_id == unknown_concept.concept_id
75
+
76
+ ConceptName.find_by_concept_id(order.concept_id)&.name || 'Unknown'
77
+ end
78
+
79
+ def tests
80
+ tests = order.tests.map do |test|
81
+ name = short_concept_name(test.value_coded) || 'Unknown'
82
+
83
+ next 'VL' if name.match?(/Viral load/i)
84
+
85
+ name.size > 7 ? name[0..6] : name
86
+ end
87
+
88
+ tests.join(', ')
89
+ end
90
+
91
+ def short_concept_name(concept_id)
92
+ ConceptName.where(concept_id: concept_id)
93
+ .min_by { |concept| concept.name.size }
94
+ &.name
95
+ end
96
+
97
+ def unknown_concept
98
+ ConceptName.find_by_name('Unknown')
99
+ end
100
+
101
+ def auto12epl
102
+ Auto12Epl.new
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ module Api
6
+ ##
7
+ # A LIMS Api wrappper that does nothing really.
8
+ #
9
+ # Primarily meant as a dummy for testing environments.
10
+ class BlackholeApi
11
+ def create_order(order_dto); end
12
+
13
+ def update_order(order_dto); end
14
+
15
+ def void_order(order_dto); end
16
+
17
+ def consume_orders(&_block); end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,53 @@
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
+ module Api
10
+ ##
11
+ # Talk to LIMS like a boss
12
+ class CouchDbApi
13
+ attr_reader :bum
14
+
15
+ def initialize(config: nil)
16
+ config ||= Config.couchdb
17
+
18
+ @bum = CouchBum.new(protocol: config['protocol'],
19
+ host: config['host'],
20
+ port: config['port'],
21
+ database: "#{config['prefix']}_order_#{config['suffix']}",
22
+ username: config['username'],
23
+ password: config['password'])
24
+ end
25
+
26
+ ##
27
+ # Consume orders from the LIMS queue.
28
+ #
29
+ # Retrieves orders from the LIMS queue and passes each order to
30
+ # given block until the queue is empty or connection is terminated
31
+ # by calling method +choke+.
32
+ def consume_orders(from: 0, limit: 30)
33
+ bum.binge_changes(since: from, limit: limit, include_docs: true) do |change|
34
+ next unless change['doc']['type']&.casecmp?('Order')
35
+
36
+ yield OrderDTO.new(change['doc']), self
37
+ end
38
+ end
39
+
40
+ def create_order(order)
41
+ order = order.dup
42
+ order.delete('_id')
43
+
44
+ bum.couch_rest :post, '/', order
45
+ end
46
+
47
+ def update_order(id, order)
48
+ bum.couch_rest :put, "/#{id}", order
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab
4
+ module Lims
5
+ module Api
6
+ class MysqlApi
7
+ def self.start
8
+ instance = MysqlApi.new
9
+ orders_processed = 0
10
+ instance.consume_orders(from: 0, limit: 1000) do |order|
11
+ puts "Order ##{orders_processed}"
12
+ pp order
13
+ orders_processed += 1
14
+ puts
15
+ end
16
+ end
17
+
18
+ def initialize(processes: 1, on_merge_processes: nil)
19
+ @processes = processes
20
+ @on_merge_processes = on_merge_processes
21
+ @mysql_connection_pool = {}
22
+ end
23
+
24
+ def multiprocessed?
25
+ @processes > 1
26
+ end
27
+
28
+ def consume_orders(from: nil, limit: 1000)
29
+ loop do
30
+ specimens_to_process = specimens(from, limit)
31
+ break if specimens_to_process.size.zero?
32
+
33
+ processes = multiprocessed? ? @processes : 0
34
+ on_merge_processes = ->(_item, index, _result) { @on_merge_processes&.call(from + index) }
35
+
36
+ Parallel.map(specimens_to_process, in_processes: processes, finish: on_merge_processes) do |specimen|
37
+ User.current ||= Utils.lab_user
38
+
39
+ tests = specimen_tests(specimen['specimen_id'])
40
+ results = tests.each_with_object({}) do |test, object|
41
+ object[test['test_name']] = test_results(test['test_id'])
42
+ end
43
+
44
+ dto = make_order_dto(
45
+ specimen: specimen,
46
+ patient: specimen_patient(specimen['specimen_id']),
47
+ test_results: results,
48
+ specimen_status_trail: specimen_status_trail(specimen['specimen_id']),
49
+ test_status_trail: tests.each_with_object({}) do |test, trails|
50
+ trails[test['test_name']] = test_status_trail(test['test_id'])
51
+ end
52
+ )
53
+
54
+ yield dto, OpenStruct.new(last_seq: from)
55
+ end
56
+
57
+ from += limit
58
+ end
59
+ end
60
+
61
+ def parallel_map(items, on_merge: nil, &block); end
62
+
63
+ private
64
+
65
+ def specimens(start_id, limit)
66
+ query = <<~SQL
67
+ SELECT specimen.id AS specimen_id,
68
+ specimen.couch_id AS doc_id,
69
+ specimen_types.name AS specimen_name,
70
+ specimen.tracking_number,
71
+ specimen.priority,
72
+ specimen.target_lab,
73
+ specimen.sending_facility,
74
+ specimen.drawn_by_id,
75
+ specimen.drawn_by_name,
76
+ specimen.drawn_by_phone_number,
77
+ specimen.ward_id,
78
+ specimen_statuses.name AS specimen_status,
79
+ specimen.district,
80
+ specimen.date_created AS order_date
81
+ FROM specimen
82
+ INNER JOIN specimen_types ON specimen_types.id = specimen.specimen_type_id
83
+ INNER JOIN specimen_statuses ON specimen_statuses.id = specimen.specimen_status_id
84
+ SQL
85
+
86
+ query = "#{query} WHERE specimen.id > #{sql_escape(start_id)}" if start_id
87
+ query = "#{query} LIMIT #{limit.to_i}"
88
+
89
+ Rails.logger.debug(query)
90
+ query(query)
91
+ end
92
+
93
+ ##
94
+ # Pull patient associated with given specimen
95
+ def specimen_patient(specimen_id)
96
+ results = query <<~SQL
97
+ SELECT patients.patient_number AS nhid,
98
+ patients.name,
99
+ patients.gender,
100
+ DATE(patients.dob) AS birthdate
101
+ FROM patients
102
+ INNER JOIN tests
103
+ ON tests.patient_id = patients.id
104
+ AND tests.specimen_id = #{sql_escape(specimen_id)}
105
+ LIMIT 1
106
+ SQL
107
+
108
+ results.first
109
+ end
110
+
111
+ def specimen_tests(specimen_id)
112
+ query <<~SQL
113
+ SELECT tests.id AS test_id,
114
+ test_types.name AS test_name,
115
+ tests.created_by AS drawn_by_name
116
+ FROM tests
117
+ INNER JOIN test_types ON test_types.id = tests.test_type_id
118
+ WHERE tests.specimen_id = #{sql_escape(specimen_id)}
119
+ SQL
120
+ end
121
+
122
+ def specimen_status_trail(specimen_id)
123
+ query <<~SQL
124
+ SELECT specimen_statuses.name AS status_name,
125
+ specimen_status_trails.who_updated_id AS updated_by_id,
126
+ specimen_status_trails.who_updated_name AS updated_by_name,
127
+ specimen_status_trails.who_updated_phone_number AS updated_by_phone_number,
128
+ specimen_status_trails.time_updated AS date
129
+ FROM specimen_status_trails
130
+ INNER JOIN specimen_statuses
131
+ ON specimen_statuses.id = specimen_status_trails.specimen_status_id
132
+ WHERE specimen_status_trails.specimen_id = #{sql_escape(specimen_id)}
133
+ SQL
134
+ end
135
+
136
+ def test_status_trail(test_id)
137
+ query <<~SQL
138
+ SELECT test_statuses.name AS status_name,
139
+ test_status_trails.who_updated_id AS updated_by_id,
140
+ test_status_trails.who_updated_name AS updated_by_name,
141
+ test_status_trails.who_updated_phone_number AS updated_by_phone_number,
142
+ COALESCE(test_status_trails.time_updated, test_status_trails.created_at) AS date
143
+ FROM test_status_trails
144
+ INNER JOIN test_statuses
145
+ ON test_statuses.id = test_status_trails.test_status_id
146
+ WHERE test_status_trails.test_id = #{sql_escape(test_id)}
147
+ SQL
148
+ end
149
+
150
+ def test_results(test_id)
151
+ query <<~SQL
152
+ SELECT measures.name AS measure_name,
153
+ test_results.result,
154
+ test_results.time_entered AS date
155
+ FROM test_results
156
+ INNER JOIN measures ON measures.id = test_results.measure_id
157
+ WHERE test_results.test_id = #{sql_escape(test_id)}
158
+ SQL
159
+ end
160
+
161
+ def make_order_dto(specimen:, patient:, test_status_trail:, specimen_status_trail:, test_results:)
162
+ drawn_by_first_name, drawn_by_last_name = specimen['drawn_by_name']&.split
163
+ patient_first_name, patient_last_name = patient['name'].split
164
+
165
+ OrderDTO.new(
166
+ _id: specimen['doc_id'].blank? ? SecureRandom.uuid : specimen['doc_id'],
167
+ _rev: '0',
168
+ tracking_number: specimen['tracking_number'],
169
+ date_created: specimen['order_date'],
170
+ sample_type: specimen['specimen_name'],
171
+ tests: test_status_trail.keys,
172
+ districy: specimen['district'], # districy [sic] - That's how it's named
173
+ order_location: specimen['ward_id'],
174
+ sending_facility: specimen['sending_facility'],
175
+ receiving_facility: specimen['target_lab'],
176
+ priority: specimen['priority'],
177
+ patient: {
178
+ id: patient['nhid'],
179
+ first_name: patient_first_name,
180
+ last_name: patient_last_name,
181
+ gender: patient['gender'],
182
+ birthdate: patient['birthdate'],
183
+ email: nil,
184
+ phone_number: nil
185
+ },
186
+ type: 'Order',
187
+ who_order_test: {
188
+ first_name: drawn_by_first_name,
189
+ last_name: drawn_by_last_name,
190
+ id: specimen['drawn_by_id'],
191
+ phone_number: specimen['drawn_by_phone_number']
192
+ },
193
+ sample_status: specimen['specimen_status'],
194
+ sample_statuses: specimen_status_trail.each_with_object({}) do |trail_entry, object|
195
+ first_name, last_name = trail_entry['updated_by_name'].split
196
+
197
+ object[format_date(trail_entry['date'])] = {
198
+ status: trail_entry['status_name'],
199
+ updated_by: {
200
+ first_name: first_name,
201
+ last_name: last_name,
202
+ phone_number: trail_entry['updated_by_phone_number'],
203
+ id: trail_entry['updated_by_id']
204
+ }
205
+ }
206
+ end,
207
+ test_statuses: test_status_trail.each_with_object({}) do |trail_entry, formatted_trail|
208
+ test_name, test_statuses = trail_entry
209
+
210
+ formatted_trail[test_name] = test_statuses.each_with_object({}) do |test_status, formatted_statuses|
211
+ updated_by_first_name, updated_by_last_name = test_status['updated_by_name'].split
212
+
213
+ formatted_statuses[format_date(test_status['date'])] = {
214
+ status: test_status['status_name'],
215
+ updated_by: {
216
+ first_name: updated_by_first_name,
217
+ last_name: updated_by_last_name,
218
+ phone_number: test_status['updated_by_phone_number'],
219
+ id: test_status['updated_by_id']
220
+ }
221
+ }
222
+ end
223
+ end,
224
+ test_results: test_results.each_with_object({}) do |results_entry, formatted_results|
225
+ test_name, results = results_entry
226
+
227
+ formatted_results[test_name] = format_test_result_for_dto(test_name, specimen, results, test_status_trail)
228
+ end
229
+ )
230
+ end
231
+
232
+ def format_test_result_for_dto(test_name, specimen, results, test_status_trail)
233
+ return {} if results.size.zero?
234
+
235
+ result_create_event = test_status_trail[test_name]&.find do |trail_entry|
236
+ trail_entry['status_name'].casecmp?('drawn')
237
+ end
238
+
239
+ result_creator_first_name, result_creator_last_name = result_create_event&.fetch('updated_by_name')&.split
240
+ unless result_creator_first_name
241
+ result_creator_first_name, result_creator_last_name = specimen['drawn_by_name']&.split
242
+ end
243
+
244
+ {
245
+ results: results.each_with_object({}) do |result, formatted_measures|
246
+ formatted_measures[result['measure_name']] = {
247
+ result_value: result['result']
248
+ }
249
+ end,
250
+ date_result_entered: format_date(result_create_event&.fetch('date') || specimen['order_date'], :iso),
251
+ result_entered_by: {
252
+ first_name: result_creator_first_name,
253
+ last_name: result_creator_last_name,
254
+ phone_number: result_create_event&.fetch('updated_by_phone_number') || specimen['drawn_by_phone_number'],
255
+ id: result_create_event&.fetch('updated_by_id') || specimen['updated_by_id']
256
+ }
257
+ }
258
+ end
259
+
260
+ def mysql
261
+ return mysql_connection if mysql_connection
262
+
263
+ config = lambda do |key|
264
+ @config ||= Lab::Lims::Config.database
265
+ @config['default'][key] || @config['development'][key]
266
+ end
267
+
268
+ connection = Mysql2::Client.new(host: config['host'] || 'localhost',
269
+ username: config['username'] || 'root',
270
+ password: config['password'],
271
+ port: config['port'] || '3306',
272
+ database: config['database'],
273
+ reconnect: true)
274
+
275
+ self.mysql_connection = connection
276
+ end
277
+
278
+ def pid
279
+ return -1 if Parallel.worker_number.nil?
280
+
281
+ Parallel.worker_number
282
+ end
283
+
284
+ def mysql_connection=(connection)
285
+ @mysql_connection_pool[pid] = connection
286
+ end
287
+
288
+ def mysql_connection
289
+ @mysql_connection_pool[pid]
290
+ end
291
+
292
+ def query(sql)
293
+ Rails.logger.debug("#{MysqlApi}: #{sql}")
294
+ mysql.query(sql)
295
+ end
296
+
297
+ def sql_escape(value)
298
+ mysql.escape(value.to_s)
299
+ end
300
+
301
+ ##
302
+ # Lims has some weird date formatting standards...
303
+ def format_date(date, format = nil)
304
+ date = date&.to_time
305
+
306
+ case format
307
+ when :iso
308
+ date&.strftime('%Y-%m-%d %H:%M:%S')
309
+ else
310
+ date&.strftime('%Y%m%d%H%M%S')
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end