malawi_hiv_program_reports 1.0.1 → 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/app/services/malawi_hiv_program_reports/README.md +16 -0
  3. data/app/services/malawi_hiv_program_reports/adapters/moh/custom.rb +199 -0
  4. data/app/services/malawi_hiv_program_reports/archiving_candidates.rb +130 -0
  5. data/app/services/malawi_hiv_program_reports/arv_refill_periods.rb +311 -0
  6. data/app/services/malawi_hiv_program_reports/clinic/README.md +5 -0
  7. data/app/services/malawi_hiv_program_reports/clinic/appointments_report.rb +317 -0
  8. data/app/services/malawi_hiv_program_reports/clinic/discrepancy_report.rb +42 -0
  9. data/app/services/malawi_hiv_program_reports/clinic/docs/hypertension_report.md +31 -0
  10. data/app/services/malawi_hiv_program_reports/clinic/drug_dispensations.rb +48 -0
  11. data/app/services/malawi_hiv_program_reports/clinic/external_consultation_clients.rb +69 -0
  12. data/app/services/malawi_hiv_program_reports/clinic/hypertension_report.rb +223 -0
  13. data/app/services/malawi_hiv_program_reports/clinic/ipt_coverage.rb +112 -0
  14. data/app/services/malawi_hiv_program_reports/clinic/ipt_report.rb +69 -0
  15. data/app/services/malawi_hiv_program_reports/clinic/lims_results.rb +55 -0
  16. data/app/services/malawi_hiv_program_reports/clinic/outcome_list.rb +127 -0
  17. data/app/services/malawi_hiv_program_reports/clinic/patients_alive_and_on_treatment.rb +57 -0
  18. data/app/services/malawi_hiv_program_reports/clinic/patients_due_for_viral_load.rb +39 -0
  19. data/app/services/malawi_hiv_program_reports/clinic/patients_on_antiretrovirals.rb +44 -0
  20. data/app/services/malawi_hiv_program_reports/clinic/patients_on_dtg.rb +36 -0
  21. data/app/services/malawi_hiv_program_reports/clinic/patients_on_treatment.rb +42 -0
  22. data/app/services/malawi_hiv_program_reports/clinic/patients_with_outdated_demographics.rb +173 -0
  23. data/app/services/malawi_hiv_program_reports/clinic/pregnant_patients.rb +91 -0
  24. data/app/services/malawi_hiv_program_reports/clinic/regimen_dispensation_data.rb +282 -0
  25. data/app/services/malawi_hiv_program_reports/clinic/regimen_switch.rb +456 -0
  26. data/app/services/malawi_hiv_program_reports/clinic/regimens_and_formulations.rb +182 -0
  27. data/app/services/malawi_hiv_program_reports/clinic/regimens_by_weight_and_gender.rb +108 -0
  28. data/app/services/malawi_hiv_program_reports/clinic/retention.rb +246 -0
  29. data/app/services/malawi_hiv_program_reports/clinic/stock_card_report.rb +65 -0
  30. data/app/services/malawi_hiv_program_reports/clinic/tpt_outcome.rb +494 -0
  31. data/app/services/malawi_hiv_program_reports/clinic/tx_rtt.rb +169 -0
  32. data/app/services/malawi_hiv_program_reports/clinic/viral_load.rb +292 -0
  33. data/app/services/malawi_hiv_program_reports/clinic/viral_load_disaggregated.rb +97 -0
  34. data/app/services/malawi_hiv_program_reports/clinic/viral_load_results.rb +175 -0
  35. data/app/services/malawi_hiv_program_reports/clinic/visits_report.rb +113 -0
  36. data/app/services/malawi_hiv_program_reports/clinic/vl_collection.rb +48 -0
  37. data/app/services/malawi_hiv_program_reports/cohort/outcomes.rb +338 -0
  38. data/app/services/malawi_hiv_program_reports/cohort/regimens.rb +69 -0
  39. data/app/services/malawi_hiv_program_reports/cohort/side_effects.rb +141 -0
  40. data/app/services/malawi_hiv_program_reports/cohort/tpt.rb +172 -0
  41. data/app/services/malawi_hiv_program_reports/moh/cohort.rb +278 -0
  42. data/app/services/malawi_hiv_program_reports/moh/cohort_builder.rb +2337 -0
  43. data/app/services/malawi_hiv_program_reports/moh/cohort_disaggregated.rb +608 -0
  44. data/app/services/malawi_hiv_program_reports/moh/cohort_disaggregated_additions.rb +208 -0
  45. data/app/services/malawi_hiv_program_reports/moh/cohort_disaggregated_builder.rb +526 -0
  46. data/app/services/malawi_hiv_program_reports/moh/cohort_struct.rb +219 -0
  47. data/app/services/malawi_hiv_program_reports/moh/cohort_survival_analysis.rb +203 -0
  48. data/app/services/malawi_hiv_program_reports/moh/moh_tpt.rb +223 -0
  49. data/app/services/malawi_hiv_program_reports/moh/tpt_newly_initiated.rb +235 -0
  50. data/app/services/malawi_hiv_program_reports/pepfar/defaulter_list.rb +25 -0
  51. data/app/services/malawi_hiv_program_reports/pepfar/maternal_status.rb +29 -0
  52. data/app/services/malawi_hiv_program_reports/pepfar/patient_start_vl.rb +45 -0
  53. data/app/services/malawi_hiv_program_reports/pepfar/regimen_switch.rb +479 -0
  54. data/app/services/malawi_hiv_program_reports/pepfar/sc_arvdisp.rb +174 -0
  55. data/app/services/malawi_hiv_program_reports/pepfar/sc_curr.rb +98 -0
  56. data/app/services/malawi_hiv_program_reports/pepfar/tb_prev.rb +163 -0
  57. data/app/services/malawi_hiv_program_reports/pepfar/tb_prev2.rb +222 -0
  58. data/app/services/malawi_hiv_program_reports/pepfar/tb_prev3.rb +421 -0
  59. data/app/services/malawi_hiv_program_reports/pepfar/tpt_status.rb +181 -0
  60. data/app/services/malawi_hiv_program_reports/pepfar/tx_ml.rb +181 -0
  61. data/app/services/malawi_hiv_program_reports/pepfar/tx_new.rb +205 -0
  62. data/app/services/malawi_hiv_program_reports/pepfar/tx_rtt.rb +288 -0
  63. data/app/services/malawi_hiv_program_reports/pepfar/tx_tb.rb +283 -0
  64. data/app/services/malawi_hiv_program_reports/pepfar/utils.rb +141 -0
  65. data/app/services/malawi_hiv_program_reports/pepfar/viral_load_coverage.rb +414 -0
  66. data/app/services/malawi_hiv_program_reports/pepfar/viral_load_coverage2.rb +433 -0
  67. data/app/services/malawi_hiv_program_reports/report_map.rb +56 -0
  68. data/app/services/malawi_hiv_program_reports/utils/README.md +8 -0
  69. data/app/services/malawi_hiv_program_reports/utils/common_sql_query_utils.rb +60 -0
  70. data/app/services/malawi_hiv_program_reports/utils/concurrency_utils.rb +53 -0
  71. data/app/services/malawi_hiv_program_reports/utils/docs/common_sql_query_utils.md +53 -0
  72. data/app/services/malawi_hiv_program_reports/utils/model_utils.rb +66 -0
  73. data/app/services/malawi_hiv_program_reports/utils/parameter_utils.rb +32 -0
  74. data/app/services/malawi_hiv_program_reports/utils/time_utils.rb +52 -0
  75. data/lib/malawi_hiv_program_reports/version.rb +1 -1
  76. metadata +74 -1
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MalawiHivProgramReports
4
+ module Clinic
5
+ class ViralLoad
6
+ include MalawiHivProgramReports::Utils::CommonSqlQueryUtils
7
+ include MalawiHivProgramReports::Utils::ModelUtils
8
+
9
+ def initialize(start_date:, end_date:, **kwargs)
10
+ @start_date = start_date.to_date.strftime('%Y-%m-%d 00:00:00')
11
+ @end_date = end_date.to_date.strftime('%Y-%m-%d 23:59:59')
12
+ @program = ::Program.find_by_name 'HIV PROGRAM'
13
+ @possible_milestones = possible_milestones
14
+ @use_filing_number = ::GlobalProperty.find_by(property: 'use.filing.numbers')
15
+ &.property_value
16
+ &.casecmp?('true')
17
+ @occupation = kwargs[:occupation]
18
+ end
19
+
20
+ def clients_due
21
+ clients = potential_get_clients
22
+ return [] if clients.blank?
23
+
24
+ clients_due_list = []
25
+
26
+ clients.each do |person|
27
+ vl_details = get_vl_due_details(person) # person[:patient_id], person[:appointment_date], person[:start_date])
28
+ next if vl_details.blank?
29
+
30
+ clients_due_list << vl_details
31
+ end
32
+
33
+ clients_due_list
34
+ end
35
+
36
+ def vl_results
37
+ read_results
38
+ end
39
+
40
+ private
41
+
42
+ def start_date
43
+ ActiveRecord::Base.connection.quote(@start_date)
44
+ end
45
+
46
+ def end_date
47
+ ActiveRecord::Base.connection.quote(@end_date)
48
+ end
49
+
50
+ def patient_identifier_type_id
51
+ identifier_type_name = @use_filing_number ? 'Filing Number' : 'ARV Number'
52
+ identifier_type = ::PatientIdentifierType.find_by_name!(identifier_type_name)
53
+
54
+ ActiveRecord::Base.connection.quote(identifier_type.id)
55
+ end
56
+
57
+ def program_id
58
+ ActiveRecord::Base.connection.quote(@program.program_id)
59
+ end
60
+
61
+ def closing_states
62
+ # state_concepts = ::ConceptName.where(name: ['Patient died', 'Patient transferred out', 'Treatment stopped'])
63
+ # .select(:concept_id)
64
+ # states = ::ProgramWorkflowState.where(concept_id: state_concepts)
65
+ # .joins(:program_workflow)
66
+ # .merge(::ProgramWorkflow.where(program: @program))
67
+
68
+ # ::PatientState.joins(:program_workflow_state)
69
+ # .merge(states)
70
+ # .select(:state)
71
+ # .distinct(:state)
72
+ # .to_sql
73
+ ::ProgramWorkflowState.joins(:program_workflow)
74
+ .where(initial: 0, terminal: 1,
75
+ program_workflow: { program_id: @program.id })
76
+ .select(:program_workflow_state_id).to_sql
77
+ end
78
+
79
+ def potential_get_clients
80
+ observations = ActiveRecord::Base.connection.select_all <<~SQL
81
+ SELECT obs.person_id,
82
+ obs.value_datetime,
83
+ date_antiretrovirals_started(obs.person_id, NULL) AS start_date,
84
+ patient_identifier.identifier,
85
+ person_name.given_name,
86
+ person_name.family_name,
87
+ person.birthdate,
88
+ person.gender
89
+ FROM obs
90
+ INNER JOIN encounter
91
+ ON encounter.encounter_id = obs.encounter_id
92
+ AND encounter.program_id = #{program_id}
93
+ AND encounter_type = (
94
+ SELECT encounter_type_id
95
+ FROM encounter_type
96
+ WHERE encounter_type.name = 'Appointment'
97
+ AND encounter_type.retired = 0
98
+ LIMIT 1
99
+ )
100
+ AND encounter.voided = 0
101
+ LEFT JOIN person
102
+ ON person.person_id = obs.person_id
103
+ AND person.voided = 0
104
+ LEFT JOIN person_name
105
+ ON person_name.person_id = obs.person_id
106
+ AND person_name.voided = 0
107
+ LEFT JOIN patient_identifier
108
+ ON patient_identifier.patient_id = obs.person_id
109
+ AND patient_identifier.identifier_type = #{patient_identifier_type_id}
110
+ AND patient_identifier.voided = 0
111
+ INNER JOIN patient_program
112
+ ON patient_program.program_id = encounter.program_id
113
+ AND patient_program.patient_id = encounter.patient_id
114
+ AND patient_program.voided = 0
115
+ INNER JOIN patient_state
116
+ ON patient_state.patient_program_id = patient_program.patient_program_id
117
+ AND patient_state.voided = 0
118
+ AND patient_state.state NOT IN (#{closing_states})
119
+ /* Limit states above to most recent states for each patient */
120
+ INNER JOIN (
121
+ SELECT patient_state.patient_program_id,
122
+ MAX(patient_state.start_date) AS start_date
123
+ FROM patient_state
124
+ INNER JOIN patient_program
125
+ ON patient_program.program_id = #{program_id}
126
+ AND patient_program.voided = 0
127
+ AND patient_program.patient_program_id = patient_state.patient_program_id
128
+ WHERE patient_state.start_date < DATE(#{end_date}) + INTERVAL 1 DAY
129
+ AND patient_state.voided = 0
130
+ GROUP BY patient_state.patient_program_id
131
+ ) AS patient_recent_state_dates
132
+ ON patient_recent_state_dates.patient_program_id = patient_state.patient_program_id
133
+ AND patient_recent_state_dates.start_date = patient_state.start_date
134
+ LEFT JOIN (#{current_occupation_query}) a ON a.person_id = obs.person_id
135
+ WHERE obs.concept_id = (
136
+ SELECT concept_id
137
+ FROM concept_name
138
+ WHERE concept_name.name = 'Appointment date'
139
+ AND concept_name.voided = 0
140
+ LIMIT 1
141
+ ) #{%w[Military Civilian].include?(@occupation) ? 'AND' : ''} #{occupation_filter(occupation: @occupation, field_name: 'value', table_name: 'a', include_clause: false)}
142
+ AND obs.value_datetime >= DATE(#{start_date})
143
+ AND obs.value_datetime < DATE(#{end_date}) + INTERVAL 1 DAY
144
+ AND obs.voided = 0
145
+ GROUP BY obs.person_id
146
+ ORDER BY obs.value_datetime
147
+ SQL
148
+
149
+ observations.map do |ob|
150
+ {
151
+ patient_id: ob['person_id'].to_i,
152
+ appointment_date: ob['value_datetime'],
153
+ start_date: ob['start_date'],
154
+ given_name: ob['given_name'],
155
+ family_name: ob['family_name'],
156
+ birthdate: ob['birthdate'],
157
+ gender: ob['gender'],
158
+ arv_number: ob['identifier']
159
+ }
160
+ end
161
+ end
162
+
163
+ # patient_id, appointment_date, patient_start_date)
164
+ def get_vl_due_details(person)
165
+ patient_start_date = begin
166
+ person[:start_date].to_date
167
+ rescue StandardError
168
+ nil
169
+ end
170
+ return if patient_start_date.blank?
171
+
172
+ # start_date = patient_start_date
173
+ appointment_date = person[:appointment_date].to_date
174
+ # months_on_art = date_diff(patient_start_date.to_date, @end_date.to_date)
175
+ vl_info = get_vl_due_info(person[:patient_id], appointment_date)
176
+ months_on_art = vl_info[:period_on_art]
177
+
178
+ # if @possible_milestones.include?(months_on_art)
179
+ return unless vl_info[:eligibile] || (vl_info[:due_date] <= end_date.to_date + 28.day)
180
+
181
+ last_result = last_vl_result(person[:patient_id])
182
+ {
183
+ patient_id: person[:patient_id],
184
+ mile_stone: vl_info[:due_date], # (patient_start_date.to_date + months_on_art.month).to_date,
185
+ start_date: patient_start_date,
186
+ months_on_art:,
187
+ appointment_date:,
188
+ given_name: person[:given_name],
189
+ family_name: person[:family_name],
190
+ gender: person[:gender],
191
+ birthdate: person[:birthdate],
192
+ arv_number: use_filing_number(person[:patient_id], person[:arv_number]),
193
+ last_result_order_date: begin
194
+ last_result.order_date.to_date
195
+ rescue StandardError
196
+ 'N/A'
197
+ end,
198
+ last_result: last_result.result_value,
199
+ last_result_date: last_result.result_date
200
+ }
201
+ end
202
+
203
+ def date_diff(date1, date2)
204
+ diff_cal = ActiveRecord::Base.connection.select_one <<~SQL
205
+ SELECT TIMESTAMPDIFF(MONTH, DATE('#{date1.to_date}'), DATE('#{date2.to_date}')) AS months;
206
+ SQL
207
+
208
+ diff_cal['months'].to_i
209
+ end
210
+
211
+ def possible_milestones
212
+ milestones = [6]
213
+ start_month = 6
214
+
215
+ 1.upto(1000).each do |_y|
216
+ milestones << (start_month += 12)
217
+ end
218
+
219
+ milestones
220
+ end
221
+
222
+ def read_results
223
+ all_results = LaboratoryService::Reports::Clinic::ProcessedResults.new(start_date: @start_date,
224
+ end_date: @end_date,
225
+ occupation: @occupation).read
226
+ processed_vl_results = []
227
+
228
+ all_results.each do |result|
229
+ measures = result[:measures]
230
+ measures.each do |measure|
231
+ next unless measure[:name].include?('viral load')
232
+
233
+ processed_vl_results << {
234
+ accession_number: result[:accession_number],
235
+ result_date: result[:result_date],
236
+ patient_id: result[:patient_id],
237
+ order_date: result[:order_date],
238
+ specimen: result[:test],
239
+ gender: result[:gender],
240
+ arv_number: result[:arv_number],
241
+ birthdate: result[:birthdate],
242
+ age_group: result[:age_group],
243
+ result: measure[:value],
244
+ result_modifier: measure[:modifier]
245
+ }
246
+ end
247
+ end
248
+
249
+ processed_vl_results
250
+ end
251
+
252
+ def last_vl_result(patient_id)
253
+ viral_load_concept = ::ConceptName.where(name: 'HIV Viral Load').select(:concept_id)
254
+ result_sql = <<~SQL
255
+ INNER JOIN obs AS parent
256
+ ON parent.obs_id = obs.obs_group_id
257
+ AND parent.concept_id IN (SELECT concept_id FROM concept_name WHERE name = 'Lab test result' AND voided = 0)
258
+ AND parent.voided = 0
259
+ AND parent.person_id = #{patient_id}
260
+ SQL
261
+
262
+ measure = ::Observation.joins(result_sql)
263
+ .where(concept: viral_load_concept)
264
+ .where('(obs.value_numeric IS NOT NULL OR obs.value_text IS NOT NULL)
265
+ AND obs.obs_datetime < DATE(?) + INTERVAL 1 DAY',
266
+ @end_date)
267
+ .order(obs_datetime: :desc)
268
+ .first
269
+
270
+ return OpenStruct.new(order_date: 'N/A', result_date: 'N/A', result_value: 'N/A') unless measure
271
+
272
+ OpenStruct.new(order_date: measure&.order&.start_date&.to_date,
273
+ result_date: measure&.obs_datetime&.to_date,
274
+ result_value: "#{measure&.value_modifier || '='}#{measure&.value_numeric || measure&.value_text}")
275
+ end
276
+
277
+ def use_filing_number(patient_id, arv_number)
278
+ return arv_number unless @use_filing_number
279
+
280
+ identifier_types = ::PatientIdentifierType.where("name LIKE '%Filing number%'").map(&:patient_identifier_type_id)
281
+ filing_numbers = PatientIdentifier.where('patient_id = ? AND identifier_type IN(?)',
282
+ patient_id, identifier_types)
283
+ filing_numbers.blank? ? '' : filing_numbers.last.identifier
284
+ end
285
+
286
+ def get_vl_due_info(patient_id, appointment_date)
287
+ vl_info = ArtService::VlReminder.new(patient_id:, date: appointment_date)
288
+ vl_info.vl_reminder_info
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MalawiHivProgramReports
4
+ module Clinic
5
+ class ViralLoadDisaggregated
6
+ attr_reader :start_date, :end_date, :from, :to
7
+
8
+ def initialize(start_date:, end_date:, from: nil, to: nil, **_kwargs)
9
+ @start_date = start_date.to_date
10
+ @end_date = end_date.to_date
11
+ @from = from&.to_f
12
+ @to = to&.to_f
13
+ end
14
+
15
+ def find_report
16
+ patients.each_with_object({}) do |patient, report|
17
+ age_group = find_age_group(patient.birthdate)
18
+ regimen = patient.regimen&.upcase
19
+ report[age_group] ||= {}
20
+
21
+ if report[age_group].include?(regimen)
22
+ report[age_group][regimen] += 1
23
+ else
24
+ report[age_group][regimen] = 1
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ AGE_GROUPS = [
32
+ [1, 4],
33
+ [5, 9],
34
+ [10, 14],
35
+ [15, 19],
36
+ [20, 24],
37
+ [25, 29],
38
+ [30, 34],
39
+ [35, 39],
40
+ [40, 44],
41
+ [45, 49],
42
+ [50, 54],
43
+ [55, 59],
44
+ [60, 64],
45
+ [65, 69],
46
+ [70, 74],
47
+ [75, 79],
48
+ [80, 84],
49
+ [85, 89],
50
+ [90, Float::INFINITY]
51
+ ].freeze
52
+
53
+ # Returns all patients with a viral load in the selected range
54
+ # and the regimens they are on the given point in time.
55
+ def patients
56
+ ::Observation.select('patient_id, birthdate, patient_current_regimen(patient_id, obs_datetime) as regimen')
57
+ .joins(:encounter)
58
+ .joins('INNER JOIN person USING (person_id)')
59
+ .where(concept_id: ::ConceptName.find_by_name('Viral load').concept_id,
60
+ obs_datetime: (start_date..end_date),
61
+ value_numeric: viral_load_range)
62
+ .merge(::Encounter.where(encounter_type: ::EncounterType.find_by_name('LAB ORDERS').encounter_type_id))
63
+ .group(:person_id)
64
+ .order(obs_datetime: :desc)
65
+ end
66
+
67
+ DAYS_IN_YEAR = 365
68
+
69
+ def find_age_group(birthdate)
70
+ return 'Unknown' unless birthdate
71
+
72
+ age = (end_date.to_date - birthdate.to_date).to_i / DAYS_IN_YEAR
73
+ return '<1 year' if age < 1
74
+
75
+ start_age, end_age = AGE_GROUPS.find { |start_age, end_age| (start_age..end_age).cover?(age) }
76
+
77
+ if end_age == Float::INFINITY
78
+ "#{start_age} years +"
79
+ else
80
+ "#{start_age} - #{end_age} years"
81
+ end
82
+ end
83
+
84
+ def viral_load_range
85
+ if from && to
86
+ from..to
87
+ elsif from
88
+ from..Float::INFINITY
89
+ elsif to
90
+ 0..to
91
+ else
92
+ 0...1
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Pulls patient's whose most recent viral load result in selected reporting period
5
+ # is in specified viral load classification.
6
+ #
7
+ # Classification are passed as parameter range and are limited to the following
8
+ # values:
9
+ # - suppressed
10
+ # - low-level-viraemia
11
+ # - viraemia-1000
12
+ #
13
+ # The classifications above follow the ART guidelines 2018 Addendum.
14
+ module MalawiHivProgramReports
15
+ module Clinic
16
+ class ViralLoadResults
17
+ include MalawiHivProgramReports::Utils::CommonSqlQueryUtils
18
+
19
+ def initialize(start_date:, end_date: nil, range: nil, **kwargs)
20
+ @start_date = start_date
21
+ @end_date = end_date
22
+ @range = range || 'viraemia-1000+'
23
+ @occupation = kwargs[:occupation]
24
+ end
25
+
26
+ def find_report
27
+ start_date = ActiveRecord::Base.connection.quote(@start_date)
28
+ end_date = ActiveRecord::Base.connection.quote(@end_date)
29
+
30
+ ActiveRecord::Base.connection.select_all <<~SQL
31
+ SELECT orders.patient_id,
32
+ patient_identifier.identifier AS arv_number,
33
+ person.birthdate AS birthdate,
34
+ disaggregated_age_group(person.birthdate, #{end_date}) AS age_group,
35
+ person.gender AS gender,
36
+ orders.start_date AS order_date,
37
+ specimen_type.name AS specimen,
38
+ COALESCE(orders.discontinued_date, orders.start_date) AS specimen_drawn_date,
39
+ test_results_obs.obs_datetime AS result_date,
40
+ COALESCE(test_result_measure_obs.value_modifier, '=') AS result_modifier,
41
+ COALESCE(test_result_measure_obs.value_numeric, test_result_measure_obs.value_text) AS result,
42
+ patient_current_regimen(orders.patient_id, orders.start_date) AS current_regimen
43
+ FROM orders
44
+ INNER JOIN concept_name AS specimen_type
45
+ ON specimen_type.concept_id = orders.concept_id
46
+ AND specimen_type.name IN ('Blood', 'DBS (Free drop to DBS card)', 'DBS (Using capillary tube)', 'Plasma')
47
+ AND specimen_type.voided = 0
48
+ LEFT JOIN patient_identifier
49
+ ON patient_identifier.patient_id = orders.patient_id
50
+ AND patient_identifier.voided = 0
51
+ AND patient_identifier.identifier_type IN (
52
+ SELECT patient_identifier_type_id FROM patient_identifier_type WHERE name = 'ARV Number' AND retired = 0
53
+ )
54
+ INNER JOIN person
55
+ ON person.person_id = orders.patient_id
56
+ AND person.voided = 0
57
+ LEFT JOIN (#{current_occupation_query}) AS a ON a.person_id = orders.patient_id
58
+ /* For each lab order find an HIV Viral Load test */
59
+ INNER JOIN obs AS test_obs
60
+ ON test_obs.order_id = orders.order_id
61
+ AND test_obs.concept_id IN (
62
+ SELECT concept_id FROM concept_name INNER JOIN concept USING (concept_id)
63
+ WHERE concept_name.name = 'Test type' AND concept.retired = 0 AND concept_name.voided = 0
64
+ )
65
+ AND test_obs.value_coded IN (
66
+ SELECT concept_id FROM concept_name INNER JOIN concept USING (concept_id)
67
+ WHERE concept_name.name = 'Viral load' AND concept.retired = 0 AND concept_name.voided = 0
68
+ )
69
+ AND test_obs.voided = 0
70
+ /* Select each test's results */
71
+ INNER JOIN obs AS test_results_obs
72
+ ON test_results_obs.obs_group_id = test_obs.obs_id
73
+ AND test_results_obs.concept_id IN (
74
+ SELECT concept_id FROM concept_name INNER JOIN concept USING (concept_id)
75
+ WHERE concept_name.name = 'Lab test result' AND concept.retired = 0 AND concept_name.voided = 0
76
+ )
77
+ AND test_results_obs.voided = 0
78
+ AND test_results_obs.obs_datetime >= DATE(#{start_date})
79
+ AND test_results_obs.obs_datetime < DATE(#{end_date}) + INTERVAL 1 DAY
80
+ /* Limit the test result's to each patient's most recent result. */
81
+ INNER JOIN (
82
+ SELECT MAX(obs_datetime) AS obs_datetime,
83
+ person_id
84
+ FROM obs
85
+ INNER JOIN orders
86
+ ON orders.order_id = obs.order_id
87
+ AND orders.order_type_id IN (SELECT order_type_id FROM order_type WHERE name = 'Lab' AND retired = 0)
88
+ AND orders.concept_id IN (
89
+ SELECT concept_id FROM concept_name INNER JOIN concept USING (concept_id)
90
+ WHERE concept_name.name IN ('Blood', 'DBS (Free drop to DBS card)', 'DBS (Using capillary tube)', 'Plasma')
91
+ AND concept.retired = 0 AND concept_name.voided = 0
92
+ )
93
+ AND orders.voided = 0
94
+ WHERE obs.concept_id IN (
95
+ SELECT concept_id FROM concept_name INNER JOIN concept USING (concept_id)
96
+ WHERE concept_name.name = 'Lab test result' AND concept.retired = 0 AND concept_name.voided = 0
97
+ )
98
+ AND obs.voided = 0
99
+ AND obs.obs_datetime >= DATE(#{start_date})
100
+ AND obs.obs_datetime < DATE(#{end_date}) + INTERVAL 1 DAY
101
+ GROUP BY person_id
102
+ ) AS max_test_results
103
+ ON max_test_results.obs_datetime = test_results_obs.obs_datetime
104
+ AND max_test_results.person_id = test_results_obs.person_id
105
+ /* Find a viral load measure that can be classified as High on the test results */
106
+ INNER JOIN obs AS test_result_measure_obs
107
+ ON test_result_measure_obs.obs_group_id = test_results_obs.obs_id
108
+ AND test_result_measure_obs.concept_id IN (
109
+ SELECT concept_id FROM concept_name INNER JOIN concept USING (concept_id)
110
+ WHERE concept_name.name = 'Viral load' AND concept.retired = 0 AND concept_name.voided = 0
111
+ )
112
+ AND (test_result_measure_obs.value_numeric IS NOT NULL
113
+ OR test_result_measure_obs.value_text IS NOT NULL)
114
+ AND test_result_measure_obs.voided = 0
115
+ AND (#{query_range})
116
+ WHERE orders.order_type_id IN (SELECT order_type_id FROM order_type WHERE name = 'Lab' AND retired = 0)
117
+ AND orders.voided = 0 #{%w[Military Civilian].include?(@occupation) ? 'AND' : ''} #{occupation_filter(occupation: @occupation, field_name: 'value', table_name: 'a', include_clause: false)}
118
+ GROUP BY orders.patient_id
119
+ SQL
120
+ end
121
+
122
+ def specimen_types
123
+ ::Concept.joins(:concept_names)
124
+ .merge(::ConceptName.where(name: ['Blood']))
125
+ .select(:concept_id)
126
+ .to_sql
127
+ end
128
+
129
+ def dbs_query_range
130
+ case @range.downcase
131
+ when 'suppressed' then <<~SQL
132
+
133
+ SQL
134
+ when 'low-level-viremia' then <<~SQL
135
+
136
+ SQL
137
+ when 'viraemia-1000+' then <<~SQL
138
+ (test_result_measure_obs.value_numeric >= 1000)
139
+ SQL
140
+ else
141
+ raise ::InvalidParameterError, "Invalid viral load range: #{@range}"
142
+ end
143
+ end
144
+
145
+ def query_range
146
+ case @range.downcase
147
+ when 'suppressed' then <<~SQL
148
+ (/* Plasma/Blood */
149
+ (specimen_type.name IN ('Blood', 'Plasma')
150
+ AND ((test_result_measure_obs.value_modifier IN ('<', '=') AND test_result_measure_obs.value_text = 'LDL')
151
+ OR (test_result_measure_obs.value_modifier = '<' AND test_result_measure_obs.value_numeric IN (20, 40, 150)))
152
+ OR (test_result_measure_obs.value_numeric >= 20 AND test_result_measure_obs.value_numeric < 200))
153
+ /* DBS */
154
+ OR (specimen_type.name IN ('DBS (Free drop to DBS card)', 'DBS (Using capillary tube)')
155
+ AND (test_result_measure_obs.value_modifier IN ('<', '=') AND test_result_measure_obs.value_text = 'LDL')))
156
+ SQL
157
+ when 'low-level-viraemia' then <<~SQL
158
+ (/* Plasma/Blood */
159
+ (specimen_type.name IN ('Blood', 'Plasma')
160
+ AND (test_result_measure_obs.value_numeric >= 200 AND test_result_measure_obs.value_numeric < 1000))
161
+ /* DBS */
162
+ OR (specimen_type.name IN ('DBS (Free drop to DBS card)', 'DBS (Using capillary tube)')
163
+ AND (test_result_measure_obs.value_modifier = '<' AND test_result_measure_obs.value_numeric IN (400, 550, 839))
164
+ OR (test_result_measure_obs.value_numeric >= 400 AND test_result_measure_obs.value_numeric < 1000)))
165
+ SQL
166
+ when 'viraemia-1000' then <<~SQL
167
+ (test_result_measure_obs.value_numeric >= 1000)
168
+ SQL
169
+ else
170
+ raise ::InvalidParameterError, "Invalid viral load range: #{@range}"
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MalawiHivProgramReports
4
+ module Clinic
5
+ class VisitsReport
6
+ include MalawiHivProgramReports::Utils::ModelUtils
7
+
8
+ def initialize(name:, type:, start_date:, end_date:)
9
+ @name = name
10
+ @type = type
11
+ @start_date = start_date.to_date
12
+ @end_date = end_date.to_date
13
+ end
14
+
15
+ def build_report
16
+ visits = (@start_date..@end_date).each_with_object({}) do |date, visits|
17
+ visits[date] ||= { incomplete: 0, complete: 0 }
18
+
19
+ find_visiting_patients(date).each do |patient|
20
+ if workflow_engine(patient, date).next_encounter
21
+ visits[date][:incomplete] += 1
22
+ else
23
+ visits[date][:complete] += 1
24
+ end
25
+ end
26
+ end
27
+
28
+ save_report visits
29
+ end
30
+
31
+ def find_report
32
+ (@start_date..@end_date).each_with_object({}) do |date, parsed_report|
33
+ report = fetch_report date
34
+
35
+ parsed_values = report.values.each_with_object({}) do |report_value, parsed_values|
36
+ parsed_values[report_value.indicator_name] = report_value.contents.to_i
37
+ end
38
+
39
+ break nil if parsed_values.empty? # Force regeneration of report
40
+
41
+ parsed_report[date] = parsed_values
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # Returns a list of patients who visited the ART clinic on given day.
48
+ def find_visiting_patients(date)
49
+ day_start, day_end = ::TimeUtils.day_bounds(date)
50
+ query = <<~SQL
51
+ SELECT patient.* FROM patient INNER JOIN encounter USING (patient_id)
52
+ WHERE encounter.encounter_datetime BETWEEN ? AND ?
53
+ AND encounter.encounter_type NOT IN (
54
+ SELECT encounter_type_id FROM encounter_type WHERE name IN ('LAB', 'LAB ORDER', 'LAB ORDERS', 'LAB RESULTS')
55
+ )
56
+ AND encounter.program_id = #{hiv_program.program_id}
57
+ AND encounter.voided = 0
58
+ AND patient.voided = 0
59
+ GROUP BY patient.patient_id
60
+ SQL
61
+
62
+ ::Patient.find_by_sql([query, day_start, day_end])
63
+ end
64
+
65
+ def workflow_engine(patient, date)
66
+ ::ArtService::WorkflowEngine.new patient:,
67
+ program: hiv_program,
68
+ date:
69
+ end
70
+
71
+ def hiv_program
72
+ @hiv_program ||= program('HIV PROGRAM')
73
+ end
74
+
75
+ def save_report(visits)
76
+ visits.each do |date, values|
77
+ report = fetch_report date
78
+
79
+ values.each do |indicator, value|
80
+ ::ReportValue.create name: "#{date} - #{indicator}",
81
+ indicator_name: indicator,
82
+ contents: value,
83
+ content_type: 'integer',
84
+ report:,
85
+ creator: ::User.first.user_id
86
+ end
87
+ end
88
+ end
89
+
90
+ def fetch_report(date)
91
+ report = ::Report.find_by type: report_type('Visits'),
92
+ name: 'Daily visits',
93
+ start_date: date,
94
+ end_date: date
95
+ report || create_report(date)
96
+ end
97
+
98
+ def create_report(date)
99
+ ::Report.create type: fetch_report_type,
100
+ name: 'Daily visits',
101
+ start_date: date,
102
+ end_date: date,
103
+ renderer_type: 'Plain text',
104
+ creator: ::User.first.user_id
105
+ end
106
+
107
+ def fetch_report_type
108
+ type = report_type('Visits')
109
+ type || ::ReportType.create(name: 'Visits', creator: ::User.current.id)
110
+ end
111
+ end
112
+ end
113
+ end