malawi_hiv_program_reports 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +2340 -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 +202 -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