diff --git a/app/forms/import_duplicate_form.rb b/app/forms/import_duplicate_form.rb index 842eb857ce..b73aba592d 100644 --- a/app/forms/import_duplicate_form.rb +++ b/app/forms/import_duplicate_form.rb @@ -35,10 +35,23 @@ def can_keep_both? end def apply_changes_options - can_keep_both? ? %w[apply discard keep_both] : %w[apply discard] + if can_apply? + can_keep_both? ? %w[apply discard keep_both] : %w[apply discard] + else + %w[discard] + end + end + + def can_apply? + !( + object.is_a?(VaccinationRecord) && + object.sourced_from_nhs_immunisations_api? + ) end def apply_pending_changes! + return unless can_apply? + object.patient.apply_pending_changes! if object.respond_to?(:patient) object.apply_pending_changes! @@ -51,7 +64,7 @@ def discard_pending_changes! end def keep_both_changes! - object.apply_pending_changes_to_new_record! if can_keep_both? + object.apply_pending_changes_to_new_record! if can_keep_both? && can_apply? end def reset_count! diff --git a/app/lib/reports/offline_session_exporter.rb b/app/lib/reports/offline_session_exporter.rb index 446e7e72f3..cdcc5e66b6 100644 --- a/app/lib/reports/offline_session_exporter.rb +++ b/app/lib/reports/offline_session_exporter.rb @@ -263,7 +263,6 @@ def add_patient_cells(row, patient:, programme:) patient_specific_directions.dig(patient.id, programme.id) triage = triages.dig(patient.id, programme.id) - row[:organisation_code] = organisation.ods_code row[:person_forename] = patient.given_name row[:person_surname] = patient.family_name row[:person_dob] = patient.date_of_birth @@ -311,6 +310,8 @@ def add_existing_row_cells(row, vaccination_record:) vaccine = vaccination_record.vaccine location = session&.location + row[:organisation_code] = vaccination_record.performed_ods_code + row[:vaccinated] = Cell.new( vaccinated(vaccination_record:), allowed_values: %w[Y N] @@ -371,6 +372,8 @@ def add_existing_row_cells(row, vaccination_record:) end def add_new_row_cells(row, patient:, programme:) + row[:organisation_code] = organisation.ods_code + row[:vaccinated] = Cell.new(allowed_values: %w[Y N]) row[:date_of_vaccination] = Cell.new(type: :date) row[:school_name] = school_name(location:, patient:) diff --git a/app/models/concerns/pending_changes_concern.rb b/app/models/concerns/pending_changes_concern.rb index 617a4e3d67..b8e8a97d98 100644 --- a/app/models/concerns/pending_changes_concern.rb +++ b/app/models/concerns/pending_changes_concern.rb @@ -55,6 +55,12 @@ def apply_pending_changes_to_new_record! private def normalised(value) - value.respond_to?(:downcase) ? value.downcase : value + if value.respond_to?(:downcase) + value.downcase + elsif value.is_a?(Time) + value.round + else + value + end end end diff --git a/app/models/immunisation_import_row.rb b/app/models/immunisation_import_row.rb index b1ad69ee23..bd4e12ff03 100644 --- a/app/models/immunisation_import_row.rb +++ b/app/models/immunisation_import_row.rb @@ -95,13 +95,16 @@ def to_vaccination_record return unless valid? outcome = (administered ? "administered" : reason_not_administered_value) - source = (offline_recording? ? "service" : "historical_upload") + source = + if imms_api_record? + "nhs_immunisations_api" + else + offline_recording? ? "service" : "historical_upload" + end attributes = { dose_sequence: dose_sequence_value, full_dose: true, - location:, - location_name:, outcome:, patient_id: patient.id, performed_at:, @@ -112,10 +115,12 @@ def to_vaccination_record session:, supplied_by: } + attributes.merge!(location:, location_name:) unless imms_api_record? attributes.merge!(notify_parents: true) if session - if performed_by_user.nil? + if performed_by_user.nil? && + (performed_by_family_name.present? || performed_by_given_name.present?) attributes.merge!( performed_by_family_name: performed_by_family_name&.to_s, performed_by_given_name: performed_by_given_name&.to_s @@ -135,8 +140,7 @@ def to_vaccination_record vaccination_record = if uuid.present? VaccinationRecord - .joins(:team) - .find_by!(teams: { id: team.id }, uuid: uuid.to_s) + .find_by!(uuid: uuid.to_s) .tap { it.stage_changes(attributes) } else VaccinationRecord.find_or_initialize_by(attributes) @@ -250,13 +254,13 @@ def location_name end def performed_at - data = date_of_vaccination.to_date + date = date_of_vaccination.to_date time = time_of_vaccination&.to_time Time.zone.local( - data.year, - data.month, - data.day, + date.year, + date.month, + date.day, time&.hour || 0, time&.min || 0, time&.sec || 0 @@ -326,7 +330,9 @@ def session end def protocol - if supplied_by && supplied_by != performed_by_user + if imms_api_record? + nil + elsif supplied_by && supplied_by != performed_by_user if patient.patient_specific_directions.exists?( programme:, academic_year:, @@ -380,6 +386,13 @@ def programmes_by_name def offline_recording? = session_id.present? + def imms_api_record? + uuid.present? && + VaccinationRecord.sourced_from_nhs_immunisations_api.exists?( + uuid: uuid.to_s + ) + end + def academic_year = date_of_vaccination.to_date.academic_year def existing_patients @@ -928,7 +941,15 @@ def validate_school_urn def validate_session_id if session_id.present? - if session_id.to_i.nil? + if uuid.present? && + VaccinationRecord.sourced_from_nhs_immunisations_api.exists?( + uuid: uuid.to_s + ) + errors.add( + session_id.header, + "A session ID cannot be provided for this record; this record was sourced from an external source." + ) + elsif session_id.to_i.nil? errors.add( session_id.header, "The session ID is not recognised. Download the offline spreadsheet " \ @@ -970,15 +991,16 @@ def validate_time_of_vaccination def validate_uuid return if uuid.blank? + scope = VaccinationRecord.left_outer_joins(:session).where(uuid: uuid.to_s) + scope = - VaccinationRecord.joins(:team).where( - teams: { - id: team.id - }, - uuid: uuid.to_s + scope.where(sessions: { team: }).or( + scope.sourced_from_nhs_immunisations_api ) - errors.add(uuid.header, "Enter an existing record.") unless scope.exists? + return if scope.exists? + + errors.add(uuid.header, "Enter an existing record.") end def validate_vaccine diff --git a/app/views/imports/issues/show.html.erb b/app/views/imports/issues/show.html.erb index d3b3e065ba..4c81185806 100644 --- a/app/views/imports/issues/show.html.erb +++ b/app/views/imports/issues/show.html.erb @@ -43,17 +43,27 @@ model: @form, url: imports_issue_path(@record, type: params[:type]), method: :patch, - class: "nhsuk-u-width-one-half", + class: "nhsuk-u-full-width", ) do |f| %> - <% content_for(:before_content) { f.govuk_error_summary } %> + <% if !@form.can_apply? %> - <%= f.govuk_collection_radio_buttons :apply_changes, - @form.apply_changes_options, - :itself, - ->(option) { I18n.t(option, scope: "import_duplicate_form.options.label.#{@existing_or_deleted}", type: @type) }, - ->(option) { I18n.t(option, scope: "import_duplicate_form.options.hint.#{@existing_or_deleted}") }, - bold_labels: false, - small: true %> +

You cannot keep the incoming changes

- <%= f.govuk_submit "Resolve duplicate" %> +

The existing record was imported automatically from an external source, such as a GP practice, meaning that Mavis is not the primary source for this vaccination record.

+ + <%= f.hidden_field :apply_changes, value: "discard" %> + <%= f.govuk_submit "Discard incoming changes" %> + <% else %> + <% content_for(:before_content) { f.govuk_error_summary } %> + + <%= f.govuk_collection_radio_buttons :apply_changes, + @form.apply_changes_options, + :itself, + ->(option) { I18n.t(option, scope: "import_duplicate_form.options.label.#{@existing_or_deleted}", type: @type) }, + ->(option) { I18n.t(option, scope: "import_duplicate_form.options.hint.#{@existing_or_deleted}") }, + bold_labels: false, + small: true %> + + <%= f.govuk_submit "Resolve duplicate" %> + <% end %> <% end %> diff --git a/spec/features/hpv_vaccination_offline_spec.rb b/spec/features/vaccination_offline_spec.rb similarity index 75% rename from spec/features/hpv_vaccination_offline_spec.rb rename to spec/features/vaccination_offline_spec.rb index 98c3331243..8e5fefe1b6 100644 --- a/spec/features/hpv_vaccination_offline_spec.rb +++ b/spec/features/vaccination_offline_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe "HPV vaccination" do +describe "offline vaccination" do around do |example| travel_to(Time.zone.local(2024, 2, 1, 12)) { example.run } end @@ -66,6 +66,42 @@ and_the_vaccination_record_is_deleted_from_nhs end + scenario "User tries to edit vaccination record sourced from NHS immunisations API in offline workflow" do + given_a_flu_programme_is_underway_with_a_single_patient + and_the_patients_record_is_sourced_from_nhs_api + + when_i_choose_to_record_offline_from_a_school_session_page + and_alter_an_existing_vaccination_record_to_create_a_duplicate + and_i_upload_the_modified_csv_file + then_i_see_a_duplicate_record_needs_review + + when_i_review_the_duplicate_record + then_i_should_see_the_nhs_api_restriction_message + and_i_should_only_see_discard_option + and_i_should_not_see_radio_buttons + + when_i_discard_the_incoming_changes + then_i_should_see_a_success_message + and_the_nhs_api_record_should_remain_unchanged + + when_i_go_to_the_import_page + then_i_should_see_no_import_issues_with_the_count + end + + scenario "User can upload un-edited vaccination records sourced from NHS immunisations API in offline workflow" do + given_a_flu_programme_is_underway_with_a_single_patient + and_the_patients_record_is_sourced_from_nhs_api + + when_i_choose_to_record_offline_from_a_school_session_page + and_i_save_the_csv_file + and_i_upload_the_modified_csv_file + then_i_see_that_no_records_need_review + and_the_nhs_api_record_should_remain_unchanged + + when_i_go_to_the_import_page + then_i_should_see_no_import_issues_with_the_count + end + def given_an_hpv_programme_is_underway(clinic: false) programmes = [create(:programme, :hpv)] @@ -180,6 +216,38 @@ def given_an_hpv_programme_is_underway_with_a_single_patient ) end + def given_a_flu_programme_is_underway_with_a_single_patient + programmes = [create(:programme, :flu)] + + @team = create(:team, :with_one_nurse, :with_generic_clinic, programmes:) + school = create(:school, team: @team) + previous_date = 1.month.ago + + vaccine = programmes.first.vaccines.active.first + @batch = create(:batch, :not_expired, team: @team, vaccine:) + + @session = + create(:session, :today, team: @team, programmes:, location: school) + + @session.session_dates.create!(value: previous_date) + + @previously_vaccinated_patient = + create(:patient, session: @session, school:, year_group: 8) + end + + def and_the_patients_record_is_sourced_from_nhs_api + fhir_record = + FHIR.from_contents(file_fixture("fhir/fhir_record_full.json").read) + @nhs_api_vaccination_record = + FHIRMapper::VaccinationRecord.from_fhir_record( + fhir_record, + patient: @previously_vaccinated_patient + ) + @nhs_api_vaccination_record.performed_at = 1.month.ago + @nhs_api_vaccination_record.notes = "Imported from NHS API" + @nhs_api_vaccination_record.save! + end + def and_imms_api_sync_job_feature_is_enabled Flipper.enable(:imms_api_sync_job) Flipper.enable(:imms_api_integration) @@ -191,6 +259,65 @@ def and_imms_api_sync_job_feature_is_enabled stub_immunisations_api_delete(uuid: immunisation_uuid) end + def and_i_upload_a_file_with_duplicate_nhs_api_records + visit "/" + click_on "Import", match: :first + click_on "Import records" + choose "Vaccination records" + click_on "Continue" + + attach_file( + "immunisation_import[csv]", + "spec/fixtures/immunisation_import/valid_hpv_offline_spreadsheet.csv" + ) + click_on "Continue" + wait_for_import_to_complete(ImmunisationImport) + end + + def when_i_review_the_nhs_api_duplicate_record + click_link "Review" + end + + def then_i_should_see_the_nhs_api_restriction_message + expect(page).to have_content("You cannot keep the incoming changes") + expect(page).to have_content( + "The existing record was imported automatically from an external source, such as a GP practice, meaning that " \ + "Mavis is not the primary source for this vaccination record." + ) + end + + def and_i_should_only_see_discard_option + expect(page).to have_button("Discard incoming changes") + end + + def and_i_should_not_see_radio_buttons + expect(page).not_to have_content("Apply the uploaded changes") + expect(page).not_to have_content("Keep the existing record") + expect(page).not_to have_selector("input[type='radio']") + end + + def when_i_discard_the_incoming_changes + click_button "Discard incoming changes" + end + + def and_the_nhs_api_record_should_remain_unchanged + @nhs_api_vaccination_record.reload + expect(@nhs_api_vaccination_record.notes).to eq("Imported from NHS API") + expect(@nhs_api_vaccination_record.source).to eq("nhs_immunisations_api") + expect(@nhs_api_vaccination_record.outcome).to eq("administered") + expect(@nhs_api_vaccination_record.delivery_site).to eq( + "left_arm_upper_position" + ) + end + + def when_i_go_to_the_import_page + click_link "Import", match: :first + end + + def then_i_should_see_no_import_issues_with_the_count + expect(page).to have_content("Import issues (0)") + end + def when_i_choose_to_record_offline_from_a_school_session_page sign_in @team.users.first visit session_path(@session) @@ -235,6 +362,24 @@ def and_alter_an_existing_vaccination_record_to_create_a_duplicate File.write("tmp/modified.csv", csv_table.to_csv) end + def and_i_save_the_csv_file + expect(page.status_code).to eq(200) + + @workbook = RubyXL::Parser.parse_buffer(page.body) + @sheet = @workbook["Vaccinations"] + @headers = @sheet[0].cells.map(&:value) + + array = @workbook[0].to_a[1..].map(&:cells).map { it.map(&:value) } + csv_table = + CSV::Table.new( + array.map do |row| + CSV::Row.new(@headers, row.map { |cell| excel_cell_to_csv(cell) }) + end + ) + + File.write("tmp/modified.csv", csv_table.to_csv) + end + def when_i_change_the_vaccination_outcome_to_not_vaccinated csv_table = CSV.read("tmp/modified.csv", headers: true) @@ -479,6 +624,10 @@ def then_i_see_a_duplicate_record_needs_review ) end + def then_i_see_that_no_records_need_review + expect(page).not_to have_content("has import issues") + end + def then_i_should_see_a_success_message expect(page).to have_content("Record updated") end diff --git a/spec/forms/import_duplicate_form_spec.rb b/spec/forms/import_duplicate_form_spec.rb new file mode 100644 index 0000000000..6f6afdd8d0 --- /dev/null +++ b/spec/forms/import_duplicate_form_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +describe ImportDuplicateForm do + let(:programme) { create(:programme) } + + describe "#can_apply?" do + subject { form.can_apply? } + + let(:form) { described_class.new(object:) } + + context "with vaccination records sourced from NHS immunisations API" do + let(:object) do + create(:vaccination_record, programme:, source: :nhs_immunisations_api) + end + + it { should be false } + end + + context "with vaccination records not sourced from NHS immunisations API" do + let(:session) { create(:session, programmes: [programme]) } + let(:object) do + create(:vaccination_record, programme:, source: :service, session:) + end + + it { should be true } + end + + context "with non-vaccination record objects" do + let(:object) { create(:patient) } + + it { should be true } + end + end + + describe "validation" do + subject { form.valid? } + + let(:form) { described_class.new(apply_changes:) } + + before do + allow(form).to receive(:apply_changes_options).and_return( + apply_changes_options + ) + end + + context "when apply_changes is one of the options" do + let(:apply_changes) { "apply" } + let(:apply_changes_options) { %w[apply discard keep_both] } + + it { should be true } + end + + context "when apply_changes is not one of the options" do + let(:apply_changes) { "apply" } + let(:apply_changes_options) { %w[discard keep_both] } + + it { should be false } + end + end + + describe "#apply_changes_options" do + let(:form) { described_class.new(object:) } + + context "when object is a vaccination record" do + let(:object) do + create(:vaccination_record, programme:, source: :historical_upload) + end + + before { allow(form).to receive(:can_keep_both?).and_return(false) } + + it "returns reduced options" do + expect(form.apply_changes_options).to eq(%w[apply discard]) + end + end + + context "when object is a patient record" do + let(:object) { create(:patient) } + + before { allow(form).to receive(:can_keep_both?).and_return(true) } + + it "returns the standard options" do + expect(form.apply_changes_options).to eq(%w[apply discard keep_both]) + end + end + + context "when object is a vaccination record sourced from NHS immunisations API" do + let(:object) do + create(:vaccination_record, programme:, source: :nhs_immunisations_api) + end + + it "returns the standard options" do + expect(form.apply_changes_options).to eq(%w[discard]) + end + end + end +end diff --git a/spec/models/immunisation_import_row_spec.rb b/spec/models/immunisation_import_row_spec.rb index a10e259eb1..14d509b83e 100644 --- a/spec/models/immunisation_import_row_spec.rb +++ b/spec/models/immunisation_import_row_spec.rb @@ -794,6 +794,37 @@ end end + context "vaccination in a session, but UUID matches a record sourced from NHS immunisations API" do + let(:data) do + { + "VACCINATED" => "Y", + "PROGRAMME" => "Flu", + "SESSION_ID" => 1, + "DATE_OF_VACCINATION" => "#{AcademicYear.current}0901", + "UUID" => "12345678-1234-1234-1234-123456789abc" + } + end + + before do + create( + :vaccination_record, + programme: create(:programme, :flu), + uuid: "12345678-1234-1234-1234-123456789abc", + source: :nhs_immunisations_api + ) + end + + it "has errors" do + expect(immunisation_import_row).to be_invalid + expect(immunisation_import_row.errors["SESSION_ID"]).to eq( + [ + "A session ID cannot be provided for this record; " \ + "this record was sourced from an external source." + ] + ) + end + end + context "with valid fields for Flu" do let(:programmes) { [create(:programme, :flu)] }