his_emr_api_lab 1.1.22 → 1.1.23
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.
- checksums.yaml +4 -4
- data/MIT-LICENSE +20 -0
- data/README.md +71 -0
- data/Rakefile +32 -0
- data/app/controllers/lab/application_controller.rb +6 -0
- data/app/controllers/lab/labels_controller.rb +17 -0
- data/app/controllers/lab/orders_controller.rb +38 -0
- data/app/controllers/lab/reasons_for_test_controller.rb +9 -0
- data/app/controllers/lab/results_controller.rb +19 -0
- data/app/controllers/lab/specimen_types_controller.rb +15 -0
- data/app/controllers/lab/test_result_indicators_controller.rb +9 -0
- data/app/controllers/lab/test_types_controller.rb +15 -0
- data/app/controllers/lab/tests_controller.rb +26 -0
- data/app/jobs/lab/application_job.rb +4 -0
- data/app/jobs/lab/push_order_job.rb +12 -0
- data/app/jobs/lab/update_patient_orders_job.rb +32 -0
- data/app/jobs/lab/void_order_job.rb +17 -0
- data/app/mailers/lab/application_mailer.rb +6 -0
- data/app/models/lab/application_record.rb +5 -0
- data/app/models/lab/lab_accession_number_counter.rb +13 -0
- data/app/models/lab/lab_encounter.rb +7 -0
- data/app/models/lab/lab_order.rb +58 -0
- data/app/models/lab/lab_result.rb +31 -0
- data/app/models/lab/lab_test.rb +19 -0
- data/app/models/lab/lims_failed_import.rb +4 -0
- data/app/models/lab/lims_order_mapping.rb +10 -0
- data/app/serializers/lab/lab_order_serializer.rb +55 -0
- data/app/serializers/lab/result_serializer.rb +36 -0
- data/app/serializers/lab/test_serializer.rb +29 -0
- data/app/services/lab/accession_number_service.rb +77 -0
- data/app/services/lab/concepts_service.rb +82 -0
- data/app/services/lab/labelling_service/order_label.rb +106 -0
- data/app/services/lab/lims/api/blackhole_api.rb +21 -0
- data/app/services/lab/lims/api/couchdb_api.rb +53 -0
- data/app/services/lab/lims/api/mysql_api.rb +316 -0
- data/app/services/lab/lims/api/rest_api.rb +416 -0
- data/app/services/lab/lims/api/ws_api.rb +121 -0
- data/app/services/lab/lims/api_factory.rb +19 -0
- data/app/services/lab/lims/config.rb +100 -0
- data/app/services/lab/lims/exceptions.rb +11 -0
- data/app/services/lab/lims/migrator.rb +216 -0
- data/app/services/lab/lims/order_dto.rb +105 -0
- data/app/services/lab/lims/order_serializer.rb +244 -0
- data/app/services/lab/lims/pull_worker.rb +289 -0
- data/app/services/lab/lims/push_worker.rb +149 -0
- data/app/services/lab/lims/utils.rb +91 -0
- data/app/services/lab/lims/worker.rb +86 -0
- data/app/services/lab/metadata.rb +24 -0
- data/app/services/lab/orders_search_service.rb +66 -0
- data/app/services/lab/orders_service.rb +212 -0
- data/app/services/lab/results_service.rb +149 -0
- data/app/services/lab/tests_service.rb +93 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20210126092910_create_lab_lab_accession_number_counters.rb +12 -0
- data/db/migrate/20210310115457_create_lab_lims_order_mappings.rb +15 -0
- data/db/migrate/20210323080140_change_lims_id_to_string_in_lims_order_mapping.rb +15 -0
- data/db/migrate/20210326195504_add_order_revision_to_lims_order_mapping.rb +5 -0
- data/db/migrate/20210407071728_create_lab_lims_failed_imports.rb +19 -0
- data/db/migrate/20210610095024_fix_numeric_results_value_type.rb +20 -0
- data/db/migrate/20210807111531_add_default_to_lims_order_mapping.rb +7 -0
- data/lib/auto12epl.rb +201 -0
- data/lib/couch_bum/couch_bum.rb +92 -0
- data/lib/generators/lab/install/USAGE +9 -0
- data/lib/generators/lab/install/install_generator.rb +19 -0
- data/lib/generators/lab/install/templates/rswag-ui-lab.rb +5 -0
- data/lib/generators/lab/install/templates/start_worker.rb +32 -0
- data/lib/generators/lab/install/templates/swagger.yaml +714 -0
- data/lib/his_emr_api_lab.rb +5 -0
- data/lib/lab/engine.rb +15 -0
- data/lib/lab/version.rb +5 -0
- data/lib/logger_multiplexor.rb +38 -0
- data/lib/tasks/lab_tasks.rake +25 -0
- data/lib/tasks/loaders/data/reasons-for-test.csv +7 -0
- data/lib/tasks/loaders/data/test-measures.csv +225 -0
- data/lib/tasks/loaders/data/tests.csv +161 -0
- data/lib/tasks/loaders/loader_mixin.rb +53 -0
- data/lib/tasks/loaders/metadata_loader.rb +26 -0
- data/lib/tasks/loaders/reasons_for_test_loader.rb +23 -0
- data/lib/tasks/loaders/specimens_loader.rb +65 -0
- data/lib/tasks/loaders/test_result_indicators_loader.rb +54 -0
- 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
|