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,421 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MalawiHivProgramReports
4
+ module Pepfar
5
+ ##
6
+ # Patients who started TPT just before the start of the current
7
+ # and have finished within the current reporting period.
8
+ class TbPrev3
9
+
10
+ attr_reader :start_date, :end_date, :check_date, :cut_off_point, :occupation, :location
11
+
12
+ include MalawiHivProgramReports::Adapters::Moh::Custom
13
+ include Utils
14
+ include MalawiHivProgramReports::Utils::CommonSqlQueryUtils
15
+
16
+ def initialize(start_date:, end_date:, **kwargs)
17
+ @start_date = ActiveRecord::Base.connection.quote(start_date)
18
+ @check_date = start_date.to_date - 6.months
19
+ @cut_off_point = start_date.to_date
20
+ @end_date = ActiveRecord::Base.connection.quote(end_date)
21
+ @occupation = kwargs[:occupation]
22
+ @location = kwargs[:location]
23
+ end
24
+
25
+ def find_report
26
+ report = init_report
27
+ patients = group_patients_by_tpt_course(patients_on_tpt)
28
+
29
+ load_patients_into_report(report, patients.six_h, '6H') do |patient|
30
+ # 6H has a constant dosage of 1 pill per day
31
+ patient_completed_tpt?(patient, '6H')
32
+ end
33
+
34
+ load_patients_into_report(report, patients.three_hp, '3HP') do |patient|
35
+ # 3HP daily dosages vary by patient weight can't use easily use pills
36
+ # to determine course completion
37
+ patient_completed_tpt?(patient, '3HP')
38
+ end
39
+
40
+ report
41
+ end
42
+
43
+ def fetch_individual_report(patient_id)
44
+ individual_tpt_report(patient_id)
45
+ end
46
+
47
+ private
48
+
49
+ def init_report
50
+ pepfar_age_groups.each_with_object({}) do |age_group, report|
51
+ report[age_group] = %w[M F Unknown].each_with_object({}) do |gender, gender_sub_report|
52
+ gender_sub_report[gender] = %w[6H 3HP].each_with_object({}) do |tpt, tpt_sub_report|
53
+ tpt_sub_report[tpt] = {
54
+ started_new_on_art: [],
55
+ started_previously_on_art: [],
56
+ completed_new_on_art: [],
57
+ completed_previously_on_art: []
58
+ }
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ def load_patients_into_report(report, patients, tpt, &patient_has_completed_tpt)
65
+ patients.each do |patient|
66
+ next if patient['transfer_in'] == 1 && !patient_has_completed_tpt[patient]
67
+
68
+ age_group = patient['age_group']
69
+ gender = patient['gender']&.first&.upcase || 'Unknown'
70
+ tpt_states = find_patient_tpt_state(patient, &patient_has_completed_tpt)
71
+
72
+ tpt_states.each do |tpt_state|
73
+ report[age_group][gender][tpt][tpt_state] << patient
74
+ end
75
+ end
76
+ end
77
+
78
+ def find_patient_tpt_state(patient, &patient_has_completed_tpt)
79
+ if patient_has_completed_tpt[patient]
80
+ return %i[completed_new_on_art] if patient_new_on_art?(patient) && patient['transfer_in'] == 1
81
+
82
+ return %i[started_new_on_art completed_new_on_art] if patient_new_on_art?(patient)
83
+
84
+ return %i[completed_previously_on_art] if patient['transfer_in'] == 1
85
+
86
+ return %i[started_previously_on_art completed_previously_on_art]
87
+ end
88
+
89
+ return %i[started_new_on_art] if patient_new_on_art?(patient)
90
+
91
+ %i[started_previously_on_art]
92
+ end
93
+
94
+ def patient_new_on_art?(patient)
95
+ tpt_initiation_date = patient['tpt_initiation_date'].to_date
96
+ art_start_date = patient['art_start_date'].to_date
97
+
98
+ (tpt_initiation_date >= art_start_date) && (tpt_initiation_date < art_start_date + 180.days)
99
+ end
100
+
101
+ def patients_on_tpt
102
+ clients = fetch_patients_on_tpt.to_a
103
+ results = []
104
+ clients.each do |client|
105
+ next if client['tpt_initiation_date'].to_date > cut_off_point
106
+
107
+ result = individual_tpt_report(client['patient_id'])
108
+ next if result.blank?
109
+ next if result['tpt_initiation_date'].to_date < check_date
110
+
111
+ client['tpt_initiation_date'] = result['tpt_initiation_date']
112
+ client['total_pills_taken'] = result['total_pills_taken']
113
+ client['months_on_tpt'] = result['months_on_tpt']
114
+ client['total_days_on_medication'] = result['total_days_on_medication']
115
+ client['drug_concepts'] = result['drug_concepts']
116
+ client['transfer_in'] = result['transfer_in']
117
+ results << client
118
+ end
119
+ results
120
+ end
121
+
122
+ def fetch_patients_on_tpt
123
+ ActiveRecord::Base.connection.select_all <<~SQL
124
+ SELECT person.person_id AS patient_id,
125
+ patient_identifier.identifier AS arv_number,
126
+ DATE(MIN(orders.start_date)) AS tpt_initiation_date,
127
+ #{function_manager(function: 'date_antiretrovirals_started', location: @location, args: "person.person_id::int, MIN(denominator_patient.start_date)::date, #{@location}::int")} AS art_start_date,
128
+ #{function_manager(function: 'patient_outcome', location: @location, args: "person.person_id::int, #{@end_date}::date, #{@location}::int")} AS outcome,
129
+ person.gender,
130
+ person.birthdate,
131
+ #{function_manager(function: 'disaggregated_age_group', location: @location, args: "person.birthdate::date, #{@end_date}::date")} AS age_group
132
+ FROM person
133
+ LEFT JOIN patient_identifier
134
+ ON patient_identifier.patient_id = person.person_id
135
+ AND patient_identifier.voided = 0
136
+ AND #{in_manager(column: 'patient_identifier.identifier_type', values: "(SELECT patient_identifier_type_id FROM patient_identifier_type WHERE name = 'ARV Number')")}
137
+ #{site_manager(operator: 'AND', column: 'patient_identifier.site_id', location: @location)}
138
+ LEFT JOIN (#{current_occupation_query}) AS current_occupation ON current_occupation.person_id = person.person_id
139
+ #{site_manager(operator: 'AND', column: 'current_occupation.site_id', location: @location)}
140
+ INNER JOIN(
141
+ SELECT denominator_encounter.patient_id AS patient_id, patient_state.start_date AS start_date
142
+ FROM person
143
+ INNER JOIN patient_program
144
+ ON patient_program.patient_id = person.person_id
145
+ AND #{in_manager(column: 'patient_program.program_id', values: "(SELECT program_id FROM program WHERE name = 'HIV PROGRAM')")}
146
+ AND patient_program.voided = 0
147
+ INNER JOIN patient_state
148
+ ON patient_state.patient_program_id = patient_program.patient_program_id
149
+ AND patient_state.state = 7 /* State: 7 == On antiretrovirals */
150
+ AND patient_state.start_date < DATE(#{start_date})
151
+ AND patient_state.voided = 0
152
+ INNER JOIN encounter AS denominator_encounter
153
+ ON denominator_encounter.patient_id = patient_program.patient_id
154
+ AND #{in_manager(column: 'denominator_encounter.program_id', values: "(SELECT program_id FROM program WHERE name = 'HIV PROGRAM')")}
155
+ AND #{in_manager(column: 'denominator_encounter.encounter_type', values: "(SELECT encounter_type_id FROM encounter_type WHERE name = 'Treatment')")}
156
+ AND denominator_encounter.encounter_datetime >= #{interval_manager(date: start_date, value: 6, interval: 'MONTH', operator: '-')}
157
+ AND denominator_encounter.encounter_datetime <= DATE(#{start_date})
158
+ AND denominator_encounter.voided = 0
159
+ #{site_manager(operator: 'AND', column: 'patient_program.site_id', location: @location)}
160
+ GROUP BY denominator_encounter.patient_id, patient_state.start_date
161
+ ) AS denominator_patient ON denominator_patient.patient_id = person.person_id
162
+ INNER JOIN encounter AS prescription_encounter
163
+ ON prescription_encounter.patient_id = denominator_patient.patient_id
164
+ AND prescription_encounter.program_id = 1
165
+ AND #{in_manager(column: 'prescription_encounter.encounter_type', values: "(SELECT encounter_type_id FROM encounter_type WHERE name = 'Treatment')")}
166
+ AND prescription_encounter.encounter_datetime >= #{interval_manager(date: start_date, value: 6, interval: 'MONTH', operator: '-')}
167
+ AND prescription_encounter.encounter_datetime <= DATE(#{end_date})
168
+ AND prescription_encounter.voided = 0
169
+ INNER JOIN orders
170
+ ON orders.encounter_id = prescription_encounter.encounter_id
171
+ AND #{in_manager(column: 'orders.order_type_id', values: "(SELECT order_type_id FROM order_type WHERE name = 'Drug order')")}
172
+ AND orders.start_date >= #{interval_manager(date: start_date, value: 6, interval: 'MONTH', operator: '-')}
173
+ AND orders.start_date <= CAST(#{end_date} as DATE)
174
+ AND orders.voided = 0
175
+ INNER JOIN concept_name
176
+ ON concept_name.concept_id = orders.concept_id
177
+ AND #{in_manager(column: 'concept_name.name', values: "'Rifapentine', 'Isoniazid', 'Isoniazid/Rifapentine'" )}
178
+ INNER JOIN drug_order
179
+ ON drug_order.order_id = orders.order_id
180
+ AND drug_order.quantity > 0
181
+ WHERE person.voided = 0 #{%w[Military Civilian].include?(@occupation) ? 'AND' : ''} #{occupation_filter(occupation: @occupation, field_name: 'value', table_name: 'current_occupation', include_clause: false)}
182
+ #{site_manager(operator: 'AND', column: 'person.site_id', location: @location)}
183
+ AND person.person_id NOT IN (
184
+ /* External consultations */
185
+ SELECT DISTINCT registration_encounter.patient_id
186
+ FROM patient_program pp
187
+ INNER JOIN program p ON p.program_id = pp.program_id AND p.name = 'HIV PROGRAM' AND p.retired = 0
188
+ INNER JOIN encounter AS registration_encounter
189
+ ON registration_encounter.patient_id = pp.patient_id
190
+ AND registration_encounter.program_id = pp.program_id
191
+ AND registration_encounter.encounter_datetime < #{interval_manager(date: end_date, value: 1, interval: 'MONTH', operator: '+')}
192
+ AND registration_encounter.voided = 0
193
+ INNER JOIN (
194
+ SELECT MAX(encounter.encounter_datetime) AS encounter_datetime, encounter.patient_id
195
+ FROM encounter
196
+ INNER JOIN encounter_type
197
+ ON encounter_type.encounter_type_id = encounter.encounter_type
198
+ AND encounter_type.name = 'Registration'
199
+ INNER JOIN program
200
+ ON program.program_id = encounter.program_id
201
+ AND program.name = 'HIV PROGRAM'
202
+ WHERE encounter.encounter_datetime < CAST(#{end_date} AS DATE) AND encounter.voided = 0
203
+ GROUP BY encounter.patient_id
204
+ ) AS max_registration_encounter
205
+ ON max_registration_encounter.patient_id = registration_encounter.patient_id
206
+ AND max_registration_encounter.encounter_datetime = registration_encounter.encounter_datetime
207
+ INNER JOIN obs AS patient_type_obs
208
+ ON patient_type_obs.encounter_id = registration_encounter.encounter_id
209
+ AND #{in_manager(column: 'patient_type_obs.concept_id', values: "(SELECT concept_id FROM concept_name WHERE name = 'Type of patient' AND voided = 0)")}
210
+ AND #{in_manager(column: 'patient_type_obs.value_coded', values: "(SELECT concept_id FROM concept_name WHERE name IN ('Drug refill', 'External consultation') AND voided = 0)")}
211
+ AND patient_type_obs.voided = 0
212
+ WHERE pp.voided = 0
213
+ )
214
+ GROUP BY person.person_id #{group_by_columns('patient_identifier.identifier, person.gender, person.birthdate, denominator_patient.start_date')}
215
+ SQL
216
+ end
217
+
218
+ def individual_tpt_report(patient_id)
219
+ result = process_current_tpt_course_date(patient_id)
220
+ c_start_date = ActiveRecord::Base.connection.quote(result[:start_date])
221
+ c_end_date = ActiveRecord::Base.connection.quote(client_tpt_end_date(patient_id, c_start_date))
222
+ ActiveRecord::Base.connection.select_one <<-SQL
223
+ SELECT
224
+ CASE
225
+ WHEN tpt_transfer_in_obs.value_datetime IS NULL THEN DATE(MIN(o.start_date))
226
+ WHEN tpt_transfer_in_obs.value_datetime > MIN(o.start_date) THEN DATE(MIN(o.start_date))
227
+ ELSE DATE(tpt_transfer_in_obs.value_datetime)
228
+ END AS tpt_initiation_date,
229
+ COUNT(DISTINCT(DATE(o.start_date))) AS months_on_tpt,
230
+ SUM(dor.quantity) + SUM(CASE WHEN tpt_transfer_in_obs.value_numeric IS NOT NULL THEN tpt_transfer_in_obs.value_numeric ELSE 0 END) AS total_pills_taken,
231
+ SUM(DATEDIFF(o.auto_expire_date, o.start_date)) + SUM(CASE WHEN tpt_transfer_in_obs.value_datetime IS NOT NULL THEN DATEDIFF(tpt_transfer_in_obs.obs_datetime, tpt_transfer_in_obs.value_datetime) ElSE 0 END) AS total_days_on_medication,
232
+ GROUP_CONCAT(DISTINCT o.concept_id SEPARATOR ',') AS drug_concepts,
233
+ CASE
234
+ WHEN tpt_transfer_in_obs.value_numeric IS NOT NULL THEN 1
235
+ ELSE 0
236
+ END AS transfer_in,
237
+ MAX(o.start_date) AS last_dispensed_date,
238
+ MAX(o.auto_expire_date) AS auto_expire_date
239
+ FROM orders o
240
+ INNER JOIN concept_name cn
241
+ ON cn.concept_id = o.concept_id
242
+ AND cn.name IN ('Rifapentine', 'Isoniazid', 'Isoniazid/Rifapentine')
243
+ #{site_manager(operator: 'AND', column: 'o.site_id', location: @location)}
244
+ LEFT JOIN obs tpt_transfer_in_obs
245
+ ON tpt_transfer_in_obs.person_id = o.patient_id
246
+ AND tpt_transfer_in_obs.concept_id = #{::ConceptName.find_by_name('TPT Drugs Received').concept_id}
247
+ AND tpt_transfer_in_obs.voided = 0
248
+ #{site_manager(operator: 'AND', column: 'tpt_transfer_in_obs.site_id', location: @location)}
249
+ AND tpt_transfer_in_obs.value_drug IN (SELECT drug_id FROM drug WHERE concept_id IN (SELECT concept_id FROM concept_name WHERE name IN ('Rifapentine', 'Isoniazid', 'Isoniazid/Rifapentine')))
250
+ INNER JOIN drug_order dor
251
+ ON dor.order_id = o.order_id
252
+ AND dor.quantity > 0
253
+ WHERE DATE(o.start_date) BETWEEN DATE(#{c_start_date}) AND DATE(#{c_end_date})
254
+ AND o.order_type_id IN (SELECT order_type_id FROM order_type WHERE name = 'Drug order')
255
+ AND o.voided = 0
256
+ AND o.patient_id = #{patient_id}
257
+ GROUP BY o.patient_id
258
+ SQL
259
+ end
260
+
261
+ def process_current_tpt_course_date(patient_id)
262
+ result = client_tpt_dates(patient_id)
263
+ return { start_date: '1900-01-01', end_date: } if result.blank?
264
+
265
+ sorted_result = result.sort { |a, b| a['start_date'].to_date <=> b['start_date'].to_date }.reverse
266
+ return_date = { start_date: sorted_result.last['start_date'], end_date: }
267
+
268
+ course_interruption = result.first['course'] == '3HP' ? 1 : 2
269
+ # loop through the result array and find the first gap in the dates that equals the course interruption
270
+ sorted_result.each_with_index do |row, index|
271
+ next if index.zero?
272
+
273
+ diff = ActiveRecord::Base.connection.select_one("SELECT TIMESTAMPDIFF(MONTH,DATE('#{row['end_date']}'), DATE('#{sorted_result[index - 1]['start_date']}')) as months")['months']
274
+
275
+ if diff.to_i >= course_interruption
276
+ return_date = { start_date: sorted_result[index - 1]['start_date'], end_date: }
277
+ break
278
+ end
279
+ end
280
+ return_date
281
+ end
282
+
283
+ def client_tpt_dates(patient_id)
284
+ ActiveRecord::Base.connection.select_all <<~SQL
285
+ (
286
+ SELECT
287
+ DATE(o.value_datetime) AS start_date,
288
+ DATE(o.obs_datetime) AS end_date,
289
+ CASE
290
+ WHEN count(distinct(o.value_drug)) > 1 THEN '3HP'
291
+ WHEN o.value_drug = #{isoniazid_rifapentine_drug.drug_id} THEN '3HP'
292
+ ELSE '6H'
293
+ END AS course
294
+ FROM obs o
295
+ WHERE o.concept_id = #{::ConceptName.find_by_name('TPT Drugs Received').concept_id}
296
+ AND o.voided = 0
297
+ #{site_manager(operator: 'AND', column: 'o.site_id', location: @location)}
298
+ AND o.value_drug IN (SELECT drug_id FROM drug WHERE concept_id IN (SELECT concept_id FROM concept_name WHERE name IN ('Rifapentine', 'Isoniazid', 'Isoniazid/Rifapentine')))
299
+ AND o.person_id = #{patient_id}
300
+ AND o.value_numeric IS NOT NULL
301
+ AND DATE(o.obs_datetime) <= DATE(#{start_date})
302
+ GROUP BY DATE(o.obs_datetime)
303
+ ORDER BY DATE(o.obs_datetime) DESC
304
+ )
305
+ UNION
306
+ (
307
+ SELECT
308
+ DATE(o.start_date) AS start_date,
309
+ DATE(o.auto_expire_date) AS end_date,
310
+ CASE
311
+ WHEN count(distinct(o.concept_id)) > 1 THEN '3HP'
312
+ WHEN o.concept_id = #{isoniazid_rifapentine_concept.concept_id} THEN '3HP'
313
+ ELSE '6H'
314
+ END AS course
315
+ FROM orders o
316
+ INNER JOIN encounter e ON e.encounter_id = o.encounter_id AND e.voided = 0 AND e.program_id = 1 /* HIV PROGRAM */
317
+ INNER JOIN drug_order dor ON dor.order_id = o.order_id AND dor.quantity > 0
318
+ WHERE o.order_type_id IN (SELECT order_type_id FROM order_type WHERE name = 'Drug order')
319
+ AND o.voided = 0
320
+ #{site_manager(operator: 'AND', column: 'o.site_id', location: @location)}
321
+ AND o.concept_id IN (#{::ConceptName.where(name: ['Rifapentine', 'Isoniazid', 'Isoniazid/Rifapentine']).select(:concept_id).to_sql})
322
+ AND o.patient_id = #{patient_id}
323
+ AND o.auto_expire_date IS NOT NULL
324
+ AND DATE(o.start_date) <= DATE(#{start_date})
325
+ GROUP BY DATE(o.start_date)
326
+ ORDER BY DATE(o.start_date) DESC
327
+ )
328
+ SQL
329
+ end
330
+
331
+ def client_tpt_end_date(patient_id, start_date)
332
+ # Get patient tpt dispensations dates after the start date and before the end date
333
+ result = ActiveRecord::Base.connection.select_all <<~SQL
334
+ (
335
+ SELECT
336
+ DATE(o.value_datetime) AS start_date,
337
+ DATE(MAX(o.obs_datetime)) AS end_date,
338
+ CASE
339
+ WHEN count(distinct(o.value_drug)) > 1 THEN '3HP'
340
+ WHEN o.value_drug = #{isoniazid_rifapentine_drug.drug_id} THEN '3HP'
341
+ ELSE '6H'
342
+ END AS course
343
+ FROM obs o
344
+ WHERE o.concept_id = #{::ConceptName.find_by_name('TPT Drugs Received').concept_id}
345
+ AND o.voided = 0
346
+ #{site_manager(operator: 'AND', column: 'o.site_id', location: @location)}
347
+ AND o.value_drug IN (SELECT drug_id FROM drug WHERE concept_id IN (SELECT concept_id FROM concept_name WHERE name IN ('Rifapentine', 'Isoniazid', 'Isoniazid/Rifapentine')))
348
+ AND o.person_id = #{patient_id}
349
+ AND o.value_numeric IS NOT NULL
350
+ AND DATE(o.obs_datetime) BETWEEN DATE(#{start_date}) AND DATE(#{end_date})
351
+ GROUP BY DATE(o.obs_datetime)
352
+ ORDER BY DATE(o.obs_datetime) DESC
353
+ )
354
+ UNION
355
+ (
356
+ SELECT
357
+ DATE(o.start_date) AS start_date,
358
+ DATE(MAX(o.auto_expire_date)) AS end_date,
359
+ CASE
360
+ WHEN count(distinct(o.concept_id)) > 1 THEN '3HP'
361
+ WHEN o.concept_id = #{isoniazid_rifapentine_concept.concept_id} THEN '3HP'
362
+ ELSE '6H'
363
+ END AS course
364
+ FROM orders o
365
+ INNER JOIN encounter e ON e.encounter_id = o.encounter_id AND e.voided = 0 AND e.program_id = 1 /* HIV PROGRAM */
366
+ INNER JOIN drug_order dor ON dor.order_id = o.order_id AND dor.quantity > 0
367
+ WHERE o.order_type_id IN (SELECT order_type_id FROM order_type WHERE name = 'Drug order')
368
+ AND o.voided = 0
369
+ #{site_manager(operator: 'AND', column: 'o.site_id', location: @location)}
370
+ AND o.concept_id IN (#{::ConceptName.where(name: ['Rifapentine', 'Isoniazid', 'Isoniazid/Rifapentine']).select(:concept_id).to_sql})
371
+ AND o.patient_id = #{patient_id}
372
+ AND o.auto_expire_date IS NOT NULL
373
+ AND DATE(o.start_date) BETWEEN DATE(#{start_date}) AND DATE(#{end_date})
374
+ GROUP BY DATE(o.start_date)
375
+ ORDER BY DATE(o.start_date) DESC
376
+ )
377
+ SQL
378
+
379
+ return end_date if result.blank?
380
+
381
+ sorted_result = result.sort { |a, b| a['start_date'].to_date <=> b['start_date'].to_date }
382
+ return_date = sorted_result.last['end_date']
383
+ course_interruption = result.first['course'] == '3HP' ? 1 : 2
384
+ # use a for loop to check if there is a course interruption
385
+ sorted_result.each_with_index do |row, i|
386
+ next if i.zero?
387
+
388
+ if row['course'] != sorted_result[i - 1]['course']
389
+ return_date = sorted_result[i - 1]['end_date']
390
+ break
391
+ end
392
+ diff = ActiveRecord::Base.connection.select_one("SELECT TIMESTAMPDIFF(MONTH,DATE('#{sorted_result[i - 1]['end_date']}'),DATE('#{row['start_date']}')) as months")['months']
393
+ if diff.to_i >= course_interruption
394
+ return_date = sorted_result[i - 1]['end_date']
395
+ break
396
+ end
397
+ end
398
+ return_date
399
+ end
400
+
401
+ ##
402
+ # Groups patients into their TPT categories (ie 6H and 3HP) based on their drugs
403
+ #
404
+ # Returns an object with a three_hp and six_h methods, each of which
405
+ # is an array of patients for that category.
406
+ def group_patients_by_tpt_course(patients)
407
+ patients.each_with_object(OpenStruct.new(six_h: [], three_hp: [])) do |patient, categories|
408
+ if patient_on_3hp?(patient)
409
+ categories.three_hp << patient
410
+ else
411
+ categories.six_h << patient
412
+ end
413
+ end
414
+ end
415
+
416
+ def isoniazid_rifapentine_drug
417
+ @isoniazid_rifapentine_drug ||= ::Drug.find_by!(concept_id: isoniazid_rifapentine_concept.concept_id)
418
+ end
419
+ end
420
+ end
421
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MalawiHivProgramReports
4
+ module Pepfar
5
+ # This class is used to generate the TPT Status report for an ART patient
6
+ class TptStatus
7
+ attr_reader :start_date, :end_date, :patient_id
8
+
9
+ include Utils
10
+
11
+ def initialize(start_date:, end_date:, **kwargs)
12
+ @start_date = start_date
13
+ @end_date = end_date
14
+ @patient_id = kwargs[:patient_id]
15
+ @tpt_status = {}
16
+ end
17
+
18
+ def find_report
19
+ patient_tpt_status
20
+ rescue StandardError => e
21
+ Rails.logger.error("Error generating TPT Status report for patient #{patient_id}: #{e.message}")
22
+ Rails.logger.error(e.backtrace.join("\n"))
23
+ raise e
24
+ end
25
+
26
+ private
27
+
28
+ def patient_tpt_status
29
+ return tb_treatment_status if patient_on_tb_treatment?(patient_id)
30
+ return completed_tpt_status if patient_history_on_completed_tpt
31
+
32
+ patient = TbPrev3.new(start_date:, end_date:).fetch_individual_report(patient_id)
33
+ return default_status if patient.blank?
34
+
35
+ tpt_status_based_on_patient(patient)
36
+ end
37
+
38
+ def tb_treatment_status
39
+ {
40
+ tpt: nil,
41
+ completed: false,
42
+ tb_treatment: true,
43
+ tpt_init_date: nil,
44
+ tpt_complete_date: nil,
45
+ eligible: {
46
+ '3HP': false,
47
+ '6H': false
48
+ }
49
+ }
50
+ end
51
+
52
+ def completed_tpt_status
53
+ tpt = patient_history_on_completed_tpt.include?('IPT') ? '6H' : '3HP'
54
+ { tpt:, completed: true, tb_treatment: false, tpt_init_date: nil, tpt_complete_date: nil, tpt_end_date: nil,
55
+ eligible: {
56
+ '3HP': false,
57
+ '6H': false
58
+ } }
59
+ end
60
+
61
+ def default_status
62
+ patient = ::Patient.find(patient_id)
63
+ art_start_date = patient.art_start_date
64
+ { tpt: nil, completed: false, tb_treatment: false, tpt_init_date: nil, tpt_complete_date: nil, tpt_end_date: nil,
65
+ eligible: {
66
+ '3HP': art_start_date ? difference_in_months(end_date.to_date, art_start_date.to_date) < 3 : true,
67
+ '6H': art_start_date ? difference_in_months(end_date.to_date, art_start_date.to_date) < 3 : true
68
+ } }
69
+ end
70
+
71
+ def tpt_status_based_on_patient(patient)
72
+ tpt = determine_tpt(patient)
73
+ completed = patient_has_totally_completed_tpt?(patient, tpt)
74
+ tpt_init_date = patient['tpt_initiation_date']
75
+ tpt_current_expiry_date = patient['auto_expire_date']&.to_date
76
+ diff_in_months = difference_in_months(end_date.to_date, tpt_current_expiry_date)
77
+ art_start_date = ::Patient.find(patient_id).art_start_date
78
+ tpt_complete_date = completed ? patient['auto_expire_date']&.to_date : nil
79
+ tpt_end_date = calculate_tpt_end_date(tpt, tpt_init_date)
80
+ tpt_name = determine_tpt_name(tpt, patient)
81
+ arv_drug_runout_date = patient_arv_drug_runout_date
82
+
83
+ @tpt_status.merge!({ tpt: tpt_name, completed:, tb_treatment: false,
84
+ tpt_init_date:, tpt_complete_date:,
85
+ tpt_end_date:, art_start_date:,
86
+ art_drug_auto_expire_date: arv_drug_runout_date })
87
+ determine_eligibility(tpt, diff_in_months, art_start_date, arv_drug_runout_date)
88
+ end
89
+
90
+ def determine_tpt(patient)
91
+ patient_on_3hp?(patient) ? '3HP' : '6H'
92
+ end
93
+
94
+ def determine_tpt_name(tpt, patient)
95
+ if tpt == '6H'
96
+ 'IPT'
97
+ else
98
+ (patient['drug_concepts'].split(',').length > 1 ? '3HP (RFP + INH)' : 'INH 300 / RFP 300 (3HP)')
99
+ end
100
+ end
101
+
102
+ def calculate_tpt_end_date(tpt, tpt_init_date)
103
+ tpt == '6H' ? tpt_init_date + 6.months : tpt_init_date + 3.months
104
+ end
105
+
106
+ def determine_eligibility(tpt, diff_in_months, art_start_date, arv_drug_runout_date)
107
+ three_hp_eligible = false
108
+ six_h_eligible = false
109
+ tpt_init_date = @tpt_status[:tpt_init_date]
110
+ tpt_end_date = @tpt_status[:tpt_end_date]
111
+ case tpt
112
+ when '3HP'
113
+ # 3HP is taken 1 dose per week
114
+ # if client misses dose for less than a month, they are eligible
115
+ # if client misses more than a month:
116
+ # check if they have been on ART for less than 3 months, they are eligible
117
+ # if they have been on ART continuosly for more than 3 months, they are not eligible
118
+ three_hp_eligible = true if diff_in_months <= 1
119
+ if diff_in_months > 1 && (arv_drug_runout_date && difference_in_months(arv_drug_runout_date.to_date,
120
+ art_start_date.to_date) < 3)
121
+ # Patient defaulted for ART and TPT and was on ART for less than 3 months: patient TPT status is reset
122
+ three_hp_eligible = true
123
+ six_h_eligible = true
124
+ tpt_end_date = nil
125
+ tpt_init_date = nil
126
+ @tpt_status[:tpt] = nil
127
+ end
128
+ when '6H'
129
+ # 6H is taken 1 dose per day
130
+ # if client misses dose for less than 2 months, they are eligible
131
+ # if client misses more than a month:
132
+ # check if they have been on ART for less than 3 months, they are eligible
133
+ # if they have been on ART continuosly for more than 3 months, they are not eligible
134
+ six_h_eligible = true if diff_in_months <= 2
135
+ if diff_in_months > 2 && (arv_drug_runout_date && difference_in_months(arv_drug_runout_date.to_date,
136
+ art_start_date.to_date) < 3)
137
+ # Patient defaulted for ART and TPT and was on ART for less than 3 months: patient TPT status is reset
138
+ three_hp_eligible = true
139
+ six_h_eligible = true
140
+ tpt_end_date = nil
141
+ tpt_init_date = nil
142
+ @tpt_status[:tpt] = nil
143
+ end
144
+ end
145
+ @tpt_status.merge!({
146
+ tpt_init_date:,
147
+ tpt_end_date:,
148
+ eligible: {
149
+ '3HP': three_hp_eligible,
150
+ '6H': six_h_eligible
151
+ }
152
+ })
153
+ end
154
+
155
+ def patient_has_totally_completed_tpt?(patient, tpt)
156
+ if tpt == '3HP'
157
+ init_date = patient['tpt_initiation_date'].to_date
158
+ end_date = patient['auto_expire_date'].to_date
159
+ days_on_medication = (end_date - init_date).to_i
160
+ days_on_medication >= 80
161
+ else
162
+ patient['total_days_on_medication'].to_i >= 176
163
+ end
164
+ end
165
+
166
+ def patient_arv_drug_runout_date
167
+ ::Patient.find(patient_id).last_arv_drug_expire_date
168
+ end
169
+
170
+ def patient_history_on_completed_tpt
171
+ @patient_history_on_completed_tpt ||= ::Observation.where(person_id: patient_id,
172
+ concept_id: ::ConceptName.find_by_name('Previous TB treatment history').concept_id)
173
+ .where("value_text LIKE '%complete%' AND obs_datetime < DATE('#{end_date}') + INTERVAL 1 DAY")&.first&.value_text
174
+ end
175
+
176
+ def difference_in_months(date1, date2)
177
+ ((date1.year * 12) + date1.month) - ((date2.year * 12) + date2.month)
178
+ end
179
+ end
180
+ end
181
+ end