diff --git a/app/lib/reports/systm_one_exporter.rb b/app/lib/reports/systm_one_exporter.rb new file mode 100644 index 0000000000..11469de6cf --- /dev/null +++ b/app/lib/reports/systm_one_exporter.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +class Reports::SystmOneExporter + GENDER_CODE_MAPPINGS = { + male: "M", + female: "F", + not_specified: "U", + not_known: "U" + }.with_indifferent_access.freeze + + VACCINE_DOSE_MAPPINGS = { + "Gardasil 9" => { + "1" => "Y19a4", + "2" => "Y19a5", + "3" => "Y19a6" + } + }.freeze + + DELIVERY_SITE_MAPPINGS = { + left_arm_upper_position: "Left deltoid", + left_arm_lower_position: "Left anterior forearm", + left_thigh: "Left lateral thigh", + right_arm_upper_position: "Right deltoid", + right_arm_lower_position: "Right anterior forearm", + right_thigh: "Right lateral thigh" + }.with_indifferent_access.freeze + + def initialize(organisation:, programme:, start_date:, end_date:) + @organisation = organisation + @programme = programme + @start_date = start_date + @end_date = end_date + end + + def call + CSV.generate(headers:, write_headers: true) do |csv| + vaccination_records.each do |vaccination_record| + csv << row(vaccination_record:) + end + end + end + + def self.call(*args, **kwargs) + new(*args, **kwargs).call + end + + private_class_method :new + + private + + attr_reader :organisation, :programme, :start_date, :end_date + + def headers + [ + "Practice code", + "NHS number", + "Surname", + "Middle name", + "Forename", + "Gender", + "Date of Birth", + "House name", + "House number and road", + "Town", + "Postcode", + "Vaccination", + "Part", + "Admin date", + "Batch number", + "Expiry date", + "Dose", + "Reason", + "Site", + "Method", + "Notes" + ] + end + + def vaccination_records + scope = + programme + .vaccination_records + .joins(:organisation) + .where(organisations: { id: organisation.id }) + .merge(VaccinationRecord.administered) + .includes(:batch, :location, :programme, :vaccine, :patient) + + if start_date.present? + scope = + scope.where( + "vaccination_records.created_at >= ?", + start_date.beginning_of_day + ).or( + scope.where( + "vaccination_records.updated_at >= ?", + start_date.beginning_of_day + ) + ) + end + + if end_date.present? + scope = + scope.where( + "vaccination_records.created_at <= ?", + end_date.end_of_day + ).or( + scope.where( + "vaccination_records.updated_at <= ?", + end_date.end_of_day + ) + ) + end + + scope + end + + def row(vaccination_record:) + patient = vaccination_record.patient + + [ + practice_code(vaccination_record), # Practice code + patient.nhs_number, # NHS number + patient.family_name, # Surname + "", # Middle name (not stored) + patient.given_name, # Forename + gender_code(patient.gender_code), # Gender + patient.date_of_birth.to_fs(:uk_short), + patient.address_line_2, # House name + patient.address_line_1, # House number and road + patient.address_town, # Town + patient.address_postcode, # Postcode + vaccination(vaccination_record), # Vaccination + "", # Part + vaccination_record.performed_at.to_date.to_fs(:uk_short), # Admin date + vaccination_record.batch&.name, # Batch number + vaccination_record.batch&.expiry&.to_fs(:uk_short), # Expiry date + vaccination_record.dose_volume_ml, # Dose + reason(vaccination_record), # Reason (not specified) + site(vaccination_record), # Site + vaccination_record.delivery_method, # Method + vaccination_record.notes # Notes + ] + end + + # TODO: Needs support for community and generic clinics. + def practice_code(vaccination_record) + location = vaccination_record.session.location + + location.school? ? location.urn : location.ods_code + end + + def gender_code(code) + GENDER_CODE_MAPPINGS[code] + end + + # TODO: These mappings are valid for Hertforshire, but may not be correct for + # other SAIS teams. We'll need to check these are correct with new SAIS + # teams. + def vaccination(vaccination_record) + return if vaccination_record.not_administered? + + VACCINE_DOSE_MAPPINGS.dig( + vaccination_record.vaccine.brand, + vaccination_record.dose_sequence.to_s + ) || + "#{vaccination_record.vaccine.brand} " \ + "Part #{vaccination_record.dose_sequence}" + end + + def reason(vaccination_record) + case vaccination_record.dose_sequence + when 1, nil + "Routine" + else + "At Risk" + end + end + + # TODO: These mappings are valid for Hertforshire, but may not be correct for + # other SAIS teams. We'll need to check these are correct with new SAIS + # teams. + def site(vaccination_record) + return if vaccination_record.not_administered? + + DELIVERY_SITE_MAPPINGS.fetch(vaccination_record.delivery_site) + end +end diff --git a/app/models/vaccination_report.rb b/app/models/vaccination_report.rb index aa04ebb6fc..6cd04fca6e 100644 --- a/app/models/vaccination_report.rb +++ b/app/models/vaccination_report.rb @@ -4,12 +4,18 @@ class VaccinationReport include RequestSessionPersistable include WizardStepConcern - FILE_FORMATS = %w[careplus mavis].freeze - def self.request_session_key "vaccination_report" end + def self.file_formats(programme) + %w[careplus mavis].tap do + if Flipper.enabled?(:systm_one_exporter) && programme.hpv? + it << "systm_one" + end + end + end + attribute :date_from, :date attribute :date_to, :date attribute :file_format, :string @@ -20,7 +26,10 @@ def wizard_steps end on_wizard_step :file_format, exact: true do - validates :file_format, inclusion: { in: FILE_FORMATS } + validates :file_format, + inclusion: { + in: -> { VaccinationReport.file_formats(it.programme) } + } end def programme @@ -57,7 +66,8 @@ def csv_filename def exporter_class { careplus: Reports::CareplusExporter, - mavis: Reports::ProgrammeVaccinationsExporter + mavis: Reports::ProgrammeVaccinationsExporter, + systm_one: Reports::SystmOneExporter }.fetch(file_format.to_sym) end diff --git a/app/views/vaccination_reports/file_format.html.erb b/app/views/vaccination_reports/file_format.html.erb index 79f125300d..2bc12f55ea 100644 --- a/app/views/vaccination_reports/file_format.html.erb +++ b/app/views/vaccination_reports/file_format.html.erb @@ -8,7 +8,7 @@ <%= form_with model: @vaccination_report, url: wizard_path, method: :put do |f| %> <%= f.govuk_error_summary %> - <%= f.govuk_collection_radio_buttons :file_format, VaccinationReport::FILE_FORMATS, :itself, legend: { text: title, size: "l", tag: "h1" }, caption: { text: @programme.name } %> + <%= f.govuk_collection_radio_buttons :file_format, VaccinationReport.file_formats(@programme), :itself, legend: { text: title, size: "l", tag: "h1" }, caption: { text: @programme.name } %> <%= f.govuk_submit %> <% end %> diff --git a/config/locales/helpers.en.yml b/config/locales/helpers.en.yml index f36f5c7747..63cf2c2f2f 100644 --- a/config/locales/helpers.en.yml +++ b/config/locales/helpers.en.yml @@ -21,3 +21,4 @@ en: file_format_options: careplus: CarePlus mavis: CSV + systm_one: SystmOne diff --git a/db/seeds.rb b/db/seeds.rb index 579f23cc84..b4278e8170 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -8,7 +8,7 @@ Faker::Config.locale = "en-GB" def set_feature_flags - %i[dev_tools mesh_jobs cis2].each do |feature_flag| + %i[dev_tools mesh_jobs cis2 systm_one_export].each do |feature_flag| Flipper.add(feature_flag) unless Flipper.exist?(feature_flag) end end diff --git a/spec/features/download_vaccination_reports_spec.rb b/spec/features/download_vaccination_reports_spec.rb index 51b9030cf3..d0043114d3 100644 --- a/spec/features/download_vaccination_reports_spec.rb +++ b/spec/features/download_vaccination_reports_spec.rb @@ -31,6 +31,35 @@ then_i_download_a_mavis_file end + scenario "Download in SystmOne format" do + given_an_hpv_programme_is_underway + and_an_administered_vaccination_record_exists + and_systm_one_export_is_enabled + + when_i_go_to_the_programme + and_i_click_on_download_vaccination_report + then_i_see_the_dates_page + + when_i_enter_some_dates + then_i_see_the_file_format_page + + when_i_choose_systm_one + then_i_download_a_systm_one_file + end + + scenario "SystmOne disabled" do + given_an_hpv_programme_is_underway + and_an_administered_vaccination_record_exists + + when_i_go_to_the_programme + and_i_click_on_download_vaccination_report + then_i_see_the_dates_page + + when_i_enter_some_dates + then_i_see_the_file_format_page + and_systm_one_export_is_disabled + end + def given_an_hpv_programme_is_underway @organisation = create(:organisation, :with_one_nurse) @programme = create(:programme, :hpv, organisations: [@organisation]) @@ -66,6 +95,10 @@ def and_an_administered_vaccination_record_exists ) end + def and_systm_one_export_is_enabled + Flipper.enable(:systm_one_exporter) + end + def when_i_go_to_the_programme sign_in @organisation.users.first visit programme_path(@programme) @@ -109,6 +142,11 @@ def when_i_choose_mavis click_on "Continue" end + def when_i_choose_systm_one + choose "SystmOne" + click_on "Continue" + end + def then_i_download_a_careplus_file expect(page.status_code).to eq(200) @@ -124,4 +162,16 @@ def then_i_download_a_mavis_file "ORGANISATION_CODE,SCHOOL_URN,SCHOOL_NAME,CARE_SETTING,CLINIC_NAME,PERSON_FORENAME,PERSON_SURNAME" ) end + + def then_i_download_a_systm_one_file + expect(page.status_code).to eq(200) + + expect(page).to have_content( + "Practice code,NHS number,Surname,Middle name,Forename,Gender,Date of Birth,House name,House number and road,Town" + ) + end + + def and_systm_one_export_is_disabled + expect(page).not_to have_selector("SystmOne") + end end diff --git a/spec/lib/reports/systm_one_exporter_spec.rb b/spec/lib/reports/systm_one_exporter_spec.rb new file mode 100644 index 0000000000..c1288ce200 --- /dev/null +++ b/spec/lib/reports/systm_one_exporter_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +describe Reports::SystmOneExporter do + subject(:csv_row) { parsed_csv.first } + + before { vaccination_record } + + let(:csv) do + described_class.call( + organisation:, + programme:, + start_date: 1.month.ago.to_date, + end_date: Date.current + ) + end + let(:programme) { create(:programme, :hpv, organisations: [organisation]) } + let(:organisation) { create(:organisation, ods_code: "ABC123") } + let(:location) { create(:school) } + let(:session) do + create(:session, organisation:, programmes: [programme], location:) + end + let(:patient) { create(:patient) } + let(:vaccination_record) do + create( + :vaccination_record, + programme:, + patient:, + session:, + performed_at: 2.weeks.ago + ) + end + let(:parsed_csv) { CSV.parse(csv, headers: true) } + + it "includes the patient and vaccination details" do + expect(parsed_csv.first.to_h).to eq( + { + "Practice code" => location.urn, + "NHS number" => vaccination_record.patient.nhs_number, + "Surname" => vaccination_record.patient.family_name, + "Middle name" => "", + "Forename" => vaccination_record.patient.given_name, + "Gender" => "U", + "Date of Birth" => + vaccination_record.patient.date_of_birth.strftime("%d/%m/%Y"), + "House name" => vaccination_record.patient.address_line_2, + "House number and road" => vaccination_record.patient.address_line_1, + "Town" => vaccination_record.patient.address_town, + "Postcode" => vaccination_record.patient.address_postcode, + "Vaccination" => "Y19a4", + "Part" => "", + "Admin date" => + vaccination_record.performed_at.to_date.strftime("%d/%m/%Y"), + "Batch number" => vaccination_record.batch.name, + "Expiry date" => vaccination_record.batch.expiry.strftime("%d/%m/%Y"), + "Dose" => vaccination_record.dose_volume_ml.to_s, + "Reason" => "Routine", + "Site" => "Left deltoid", + "Method" => vaccination_record.delivery_method, + "Notes" => vaccination_record.notes + } + ) + end + + context "no vaccination details" do + before { vaccination_record.destroy } + + it { should be_blank } + end + + context "with vaccination records outside the date range" do + let(:vaccination_record) do + create( + :vaccination_record, + programme:, + patient:, + session:, + created_at: 2.months.ago, + updated_at: 2.months.ago, + performed_at: 2.months.ago + ) + end + + it { should be_blank } + end + + context "with vaccination records that haven't been administered" do + let(:vaccination_record) do + create( + :vaccination_record, + :not_administered, + programme:, + patient:, + session: + ) + end + + it { should be_blank } + end + + context "with vaccination records updated within the date range do" do + let(:vaccination_record) do + create( + :vaccination_record, + programme:, + patient:, + session:, + created_at: 2.months.ago, + updated_at: 1.day.ago, + performed_at: 2.months.ago + ) + end + + it { should_not be_blank } + end + + context "with a session in a different organisation" do + let(:programme) do + create(:programme, :hpv, organisations: [other_organisation]) + end + let(:other_organisation) { create(:organisation, ods_code: "XYZ890") } + + let(:session) do + create( + :session, + organisation: other_organisation, + programmes: [programme], + location: + ) + end + + it { should be_blank } + end + + describe "Practice code field" do + subject { csv_row["Practice code"] } + + context "location is a gp clinic" do + let(:location) { create(:gp_practice) } + + it { should eq location.ods_code } + end + end + + describe "Gender field" do + subject { csv_row["Gender"] } + + context "gender_code is male" do + let(:patient) { create(:patient, gender_code: :male) } + + it { should eq "M" } + end + + context "gender_code is female" do + let(:patient) { create(:patient, gender_code: :female) } + + it { should eq "F" } + end + + context "gender_code is not specified" do + let(:patient) { create(:patient, gender_code: :not_specified) } + + it { should eq "U" } + end + end + + describe "Vaccination field" do + subject { csv_row["Vaccination"] } + + let(:vaccination_record) do + create( + :vaccination_record, + programme:, + patient:, + session:, + performed_at: 2.weeks.ago, + vaccine:, + dose_sequence: + ) + end + + context "HPV Gardasil 9 dose 2" do + let(:vaccine) { Vaccine.find_by(brand: "Gardasil 9") } + let(:dose_sequence) { 2 } + + it { should eq "Y19a5" } + end + + context "HPV Gardasil 9 dose 3" do + let(:vaccine) { Vaccine.find_by(brand: "Gardasil 9") } + let(:dose_sequence) { 3 } + + it { should eq "Y19a6" } + end + + context "unknown vaccine and no dose sequence" do + let(:vaccine) { create(:vaccine, :fluad_tetra) } + let(:dose_sequence) { 1 } + + it { should eq "Fluad Tetra - aQIV Part 1" } + end + end + + describe "Site field" do + subject { csv_row["Site"] } + + let(:vaccination_record) do + create( + :vaccination_record, + programme:, + patient:, + session:, + performed_at: 2.weeks.ago, + delivery_site: + ) + end + + context "left arm lower position" do + let(:delivery_site) { "left_arm_lower_position" } + + it { should eq "Left anterior forearm" } + end + end +end diff --git a/spec/models/vaccination_report_spec.rb b/spec/models/vaccination_report_spec.rb new file mode 100644 index 0000000000..388c127a79 --- /dev/null +++ b/spec/models/vaccination_report_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +describe VaccinationReport do + describe "file_formats" do + subject { described_class.file_formats(programme) } + + context "when hpv and feature is disabled" do + before { Flipper.disable(:systm_one_exporter) } + + let(:programme) { create(:programme, :hpv) } + + it { should eq(%w[careplus mavis]) } + end + + context "when hpv and feature is enabled" do + before { Flipper.enable(:systm_one_exporter) } + + let(:programme) { create(:programme, :hpv) } + + it { should eq(%w[careplus mavis systm_one]) } + end + + context "when menacwy and feature is enabled" do + before { Flipper.enable(:systm_one_exporter) } + + let(:programme) { create(:programme, :menacwy) } + + it { should eq(%w[careplus mavis]) } + end + end +end