diff --git a/app/lib/mavis_cli/reports/send_to_careplus.rb b/app/lib/mavis_cli/reports/send_to_careplus.rb index 1c33eaf426..1701ec1a99 100644 --- a/app/lib/mavis_cli/reports/send_to_careplus.rb +++ b/app/lib/mavis_cli/reports/send_to_careplus.rb @@ -88,9 +88,8 @@ def resolve_credentials(ods_code:, workgroup:) team = teams.sole - unless team.careplus_username.present? && - team.careplus_password.present? - warn "Team '#{team.name}' does not have CarePlus credentials configured." + unless team.has_careplus_credentials? + warn "Team '#{team.name}' needs the CarePlus username, password, and namespace configured to send reports." return nil, nil, nil end diff --git a/app/lib/reports/automated_careplus_exporter.rb b/app/lib/reports/automated_careplus_exporter.rb index 4f82acc0de..9fa8c2a421 100644 --- a/app/lib/reports/automated_careplus_exporter.rb +++ b/app/lib/reports/automated_careplus_exporter.rb @@ -37,14 +37,44 @@ def self.vaccination_records_scope( start_date:, end_date: ) - Reports::CareplusExporter.vaccination_records_scope( - team:, - programmes: team.programmes, - academic_year:, - start_date:, - end_date:, - include_missing_nhs_number: false - ) + base_scope = + Reports::CareplusExporter.vaccination_records_scope( + team:, + programmes: team.programmes, + academic_year:, + start_date: nil, + end_date: nil, + include_missing_nhs_number: false + ) + date_range_scope = + base_scope.created_or_updated_between(start_date, end_date) + + return date_range_scope if team.careplus_automated_reports_enabled_at.blank? + + nhs_number_first_added_scope = + base_scope + .created_or_updated_on_or_after( + team.careplus_automated_reports_enabled_at + ) + .where.not(patients: { nhs_number_first_added_at: nil }) + + if start_date.present? + nhs_number_first_added_scope = + nhs_number_first_added_scope.where( + "patients.nhs_number_first_added_at >= ?", + start_date.beginning_of_day + ) + end + + if end_date.present? + nhs_number_first_added_scope = + nhs_number_first_added_scope.where( + "patients.nhs_number_first_added_at <= ?", + end_date.end_of_day + ) + end + + date_range_scope.or(nhs_number_first_added_scope).distinct end def self.shared_args(team:, academic_year:) diff --git a/app/lib/reports/careplus_exporter.rb b/app/lib/reports/careplus_exporter.rb index 3e7a4f49f7..a15355bd42 100644 --- a/app/lib/reports/careplus_exporter.rb +++ b/app/lib/reports/careplus_exporter.rb @@ -187,11 +187,13 @@ def gender_row_value(patient) end def consents + patient_ids = vaccination_records.unscope(:order).reselect(:patient_id) + @consents ||= Consent .select("DISTINCT ON (patient_id) consents.*") .for_programmes(programmes) - .where(patient: vaccination_records.select(:patient_id), academic_year:) + .where(patient: patient_ids, academic_year:) .not_invalidated .response_given .order(:patient_id, created_at: :desc) diff --git a/app/models/concerns/patient_import_concern.rb b/app/models/concerns/patient_import_concern.rb index 6886078101..e78ebbd3ec 100644 --- a/app/models/concerns/patient_import_concern.rb +++ b/app/models/concerns/patient_import_concern.rb @@ -12,6 +12,7 @@ def import_patients_and_parents(changesets, import) .uniq { [_1.parent, _1.patient] } deduplicate_patients!(patients, relationships) + patients.each(&:ensure_nhs_number_first_added_at) patients_with_nhs_number_changes = patients.select(&:nhs_number_previously_changed?) diff --git a/app/models/patient.rb b/app/models/patient.rb index 735cba29ec..a198a33ddb 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -22,6 +22,7 @@ # invalidated_at :datetime # local_authority_mhclg_code :string # nhs_number :string +# nhs_number_first_added_at :datetime # pending_changes :jsonb not null # preferred_family_name :string # preferred_given_name :string @@ -47,6 +48,7 @@ # index_patients_on_names_family_first (family_name,given_name) # index_patients_on_names_given_first (given_name,family_name) # index_patients_on_nhs_number (nhs_number) UNIQUE +# index_patients_on_nhs_number_first_added_at (nhs_number_first_added_at) # index_patients_on_pending_changes_not_empty (id) WHERE (pending_changes <> '{}'::jsonb) # index_patients_on_school_id (school_id) # @@ -495,6 +497,7 @@ class Patient < ApplicationRecord it.blank? ? nil : it.normalise_whitespace.gsub(/\s/, "") end + before_validation :ensure_nhs_number_first_added_at after_update :sync_vaccinations_to_nhs_immunisations_api after_commit :generate_important_notice_if_needed, on: :update after_commit :search_vaccinations_from_nhs_immunisations_api, on: :update @@ -810,6 +813,15 @@ def pds_lookup_match? def notifier = Notifier::Patient.new(self) + def ensure_nhs_number_first_added_at + return unless will_save_change_to_nhs_number? + + old_nhs_number, new_nhs_number = nhs_number_change_to_be_saved + return unless old_nhs_number.blank? && new_nhs_number.present? + + self.nhs_number_first_added_at ||= Time.current + end + private def locations_are_correct_type @@ -822,6 +834,17 @@ def locations_are_correct_type end end + def destroy_childless_parents + parents_to_check = parents.to_a # Store parents before destroying relationships + + # Manually destroy the parent_relationships associated with this Child + parent_relationships.each(&:destroy) + + parents_to_check.each do |parent| + parent.destroy! if parent.parent_relationships.count.zero? + end + end + def archive_due_to_deceased! archive_reasons = teams.map do |team| diff --git a/app/models/patient_changeset.rb b/app/models/patient_changeset.rb index 2b4575bc27..e839d5acee 100644 --- a/app/models/patient_changeset.rb +++ b/app/models/patient_changeset.rb @@ -383,7 +383,10 @@ def set_child_attribute_if_valid( end if in_pending_changes && !in_existing_patient - existing_patient[attribute] = child_attributes[attribute.to_s] + existing_patient.public_send( + "#{attribute}=", + child_attributes[attribute.to_s] + ) end end diff --git a/app/models/team.rb b/app/models/team.rb index 8b7adec608..ecf2c200f7 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -4,29 +4,30 @@ # # Table name: teams # -# id :bigint not null, primary key -# careplus_namespace :string -# careplus_password :string -# careplus_staff_code :string -# careplus_staff_type :string -# careplus_username :string -# careplus_venue_code :string -# days_before_consent_reminders :integer default(7), not null -# days_before_consent_requests :integer default(21), not null -# email :string -# name :text not null -# national_reporting_cut_off_date :date -# phone :string -# phone_instructions :string -# privacy_notice_url :string -# privacy_policy_url :string -# programme_types :enum not null, is an Array -# type :integer not null -# workgroup :string not null -# created_at :datetime not null -# updated_at :datetime not null -# organisation_id :bigint not null -# reply_to_id :uuid +# id :bigint not null, primary key +# careplus_automated_reports_enabled_at :datetime +# careplus_namespace :string +# careplus_password :string +# careplus_staff_code :string +# careplus_staff_type :string +# careplus_username :string +# careplus_venue_code :string +# days_before_consent_reminders :integer default(7), not null +# days_before_consent_requests :integer default(21), not null +# email :string +# name :text not null +# national_reporting_cut_off_date :date +# phone :string +# phone_instructions :string +# privacy_notice_url :string +# privacy_policy_url :string +# programme_types :enum not null, is an Array +# type :integer not null +# workgroup :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organisation_id :bigint not null +# reply_to_id :uuid # # Indexes # @@ -99,8 +100,12 @@ class Team < ApplicationRecord .where.not(careplus_username: nil) .where.not(careplus_password: nil) end + scope :careplus_automated_reports_enabled, + -> { where.not(careplus_automated_reports_enabled_at: nil) } scope :eligible_for_automated_careplus_reports, - -> { careplus_enabled.has_careplus_credentials } + -> do + careplus_enabled.has_careplus_credentials.careplus_automated_reports_enabled + end enum :type, { point_of_care: 0, national_reporting: 1, support: 2 }, @@ -172,8 +177,13 @@ def careplus_enabled? careplus_venue_code.present? end + def has_careplus_credentials? + careplus_username.present? && careplus_password.present? && + careplus_namespace.present? + end + def eligible_for_automated_careplus_reports? - careplus_enabled? && careplus_username.present? && - careplus_password.present? && careplus_namespace.present? + careplus_enabled? && has_careplus_credentials? && + careplus_automated_reports_enabled_at.present? end end diff --git a/app/models/vaccination_record.rb b/app/models/vaccination_record.rb index ca0f90f1ea..5e1fde2ee0 100644 --- a/app/models/vaccination_record.rb +++ b/app/models/vaccination_record.rb @@ -196,6 +196,13 @@ class VaccinationRecord < ApplicationRecord scope end + scope :created_or_updated_on_or_after, + ->(timestamp) do + where("vaccination_records.created_at >= ?", timestamp).or( + where("vaccination_records.updated_at >= ?", timestamp) + ) + end + enum :protocol, { pgd: 0, psd: 1, national: 2 }, validate: { allow_nil: true } enum :delivery_method, diff --git a/db/data/20260422120100_backfill_nhs_number_first_added_at_for_patients.rb b/db/data/20260422120100_backfill_nhs_number_first_added_at_for_patients.rb new file mode 100644 index 0000000000..afcb74c299 --- /dev/null +++ b/db/data/20260422120100_backfill_nhs_number_first_added_at_for_patients.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class BackfillNHSNumberFirstAddedAtForPatients < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + BATCH_SIZE = 1000 + + def up + migration = self.class.name + started_at = Time.zone.now + scope = Patient.where(nhs_number_first_added_at: nil).where.not(nhs_number: nil) + total_records = scope.count + total_batches = (total_records.to_f / BATCH_SIZE).ceil + records_updated = 0 + + Rails.logger.info( + event: "data_migration_start", + migration:, + total_records:, + batch_size: BATCH_SIZE, + total_batches: + ) + + scope.in_batches(of: BATCH_SIZE).each_with_index do |batch, index| + updated_count = batch.update_all("nhs_number_first_added_at = created_at") + records_updated += updated_count + + Rails.logger.info( + event: "data_migration_batch", + migration:, + batch_number: index + 1, + total_batches:, + updated_count:, + records_updated: + ) + end + + duration_minutes = ((Time.zone.now - started_at) / 60.0).round + + Rails.logger.info( + event: "data_migration_finish", + migration:, + duration_minutes:, + records_updated: + ) + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end \ No newline at end of file diff --git a/db/migrate/20260422120000_add_nhs_number_first_added_at_for_patient.rb b/db/migrate/20260422120000_add_nhs_number_first_added_at_for_patient.rb new file mode 100644 index 0000000000..ea67f4e7c6 --- /dev/null +++ b/db/migrate/20260422120000_add_nhs_number_first_added_at_for_patient.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddNHSNumberFirstAddedAtForPatient < ActiveRecord::Migration[8.1] + def change + add_column :patients, :nhs_number_first_added_at, :datetime + end +end diff --git a/db/migrate/20260422120500_add_index_on_patients_nhs_number_first_added_at.rb b/db/migrate/20260422120500_add_index_on_patients_nhs_number_first_added_at.rb new file mode 100644 index 0000000000..f3c0639fd0 --- /dev/null +++ b/db/migrate/20260422120500_add_index_on_patients_nhs_number_first_added_at.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddIndexOnPatientsNHSNumberFirstAddedAt < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + def change + add_index :patients, :nhs_number_first_added_at, algorithm: :concurrently + end +end diff --git a/db/migrate/20260422121000_add_careplus_automated_reports_enabled_at_for_team.rb b/db/migrate/20260422121000_add_careplus_automated_reports_enabled_at_for_team.rb new file mode 100644 index 0000000000..4e2279d5d8 --- /dev/null +++ b/db/migrate/20260422121000_add_careplus_automated_reports_enabled_at_for_team.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddCareplusAutomatedReportsEnabledAtForTeam < ActiveRecord::Migration[8.1] + def change + add_column :teams, :careplus_automated_reports_enabled_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index b648a5c2fb..fa54cca134 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -162,7 +162,6 @@ t.bigint "class_import_id", null: false t.bigint "parent_relationship_id", null: false t.index ["class_import_id", "parent_relationship_id"], name: "idx_on_class_import_id_parent_relationship_id_8225058195", unique: true - t.index ["parent_relationship_id", "class_import_id"], name: "idx_on_parent_relationship_id_class_import_id_d7c05d6c2c", unique: true end create_table "class_imports_parents", id: false, force: :cascade do |t| @@ -221,7 +220,6 @@ t.bigint "cohort_import_id", null: false t.bigint "parent_relationship_id", null: false t.index ["cohort_import_id", "parent_relationship_id"], name: "idx_on_cohort_import_id_parent_relationship_id_c65e20d1f8", unique: true - t.index ["parent_relationship_id", "cohort_import_id"], name: "idx_on_parent_relationship_id_cohort_import_id_40fb9846d6", unique: true end create_table "cohort_imports_parents", id: false, force: :cascade do |t| @@ -460,7 +458,6 @@ t.bigint "immunisation_import_id", null: false t.bigint "vaccination_record_id", null: false t.index ["immunisation_import_id", "vaccination_record_id"], name: "idx_on_immunisation_import_id_vaccination_record_id_588e859772", unique: true - t.index ["vaccination_record_id", "immunisation_import_id"], name: "idx_on_vaccination_record_id_immunisation_import_id_813c516ad7", unique: true end create_table "important_notices", force: :cascade do |t| @@ -763,6 +760,7 @@ t.datetime "invalidated_at" t.string "local_authority_mhclg_code" t.string "nhs_number" + t.datetime "nhs_number_first_added_at" t.jsonb "pending_changes", default: {}, null: false t.string "preferred_family_name" t.string "preferred_given_name" @@ -784,6 +782,7 @@ t.index ["id"], name: "index_patients_on_pending_changes_not_empty", where: "(pending_changes <> '{}'::jsonb)" t.index ["local_authority_mhclg_code"], name: "index_patients_on_local_authority_mhclg_code" t.index ["nhs_number"], name: "index_patients_on_nhs_number", unique: true + t.index ["nhs_number_first_added_at"], name: "index_patients_on_nhs_number_first_added_at" t.index ["school_id"], name: "index_patients_on_school_id" end @@ -912,6 +911,7 @@ end create_table "teams", force: :cascade do |t| + t.datetime "careplus_automated_reports_enabled_at" t.string "careplus_namespace" t.string "careplus_password" t.string "careplus_staff_code" diff --git a/spec/factories/patients.rb b/spec/factories/patients.rb index e9e1908dc8..cbedcc0848 100644 --- a/spec/factories/patients.rb +++ b/spec/factories/patients.rb @@ -22,6 +22,7 @@ # invalidated_at :datetime # local_authority_mhclg_code :string # nhs_number :string +# nhs_number_first_added_at :datetime # pending_changes :jsonb not null # preferred_family_name :string # preferred_given_name :string @@ -47,6 +48,7 @@ # index_patients_on_names_family_first (family_name,given_name) # index_patients_on_names_given_first (given_name,family_name) # index_patients_on_nhs_number (nhs_number) UNIQUE +# index_patients_on_nhs_number_first_added_at (nhs_number_first_added_at) # index_patients_on_pending_changes_not_empty (id) WHERE (pending_changes <> '{}'::jsonb) # index_patients_on_school_id (school_id) # @@ -91,6 +93,7 @@ "#{base}#{check_digit}" end end + nhs_number_first_added_at { nhs_number.present? ? Time.current : nil } given_name { Faker::Name.first_name } family_name { Faker::Name.last_name } diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb index 560c7faa94..c0818cc0d2 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -4,29 +4,30 @@ # # Table name: teams # -# id :bigint not null, primary key -# careplus_namespace :string -# careplus_password :string -# careplus_staff_code :string -# careplus_staff_type :string -# careplus_username :string -# careplus_venue_code :string -# days_before_consent_reminders :integer default(7), not null -# days_before_consent_requests :integer default(21), not null -# email :string -# name :text not null -# national_reporting_cut_off_date :date -# phone :string -# phone_instructions :string -# privacy_notice_url :string -# privacy_policy_url :string -# programme_types :enum not null, is an Array -# type :integer not null -# workgroup :string not null -# created_at :datetime not null -# updated_at :datetime not null -# organisation_id :bigint not null -# reply_to_id :uuid +# id :bigint not null, primary key +# careplus_automated_reports_enabled_at :datetime +# careplus_namespace :string +# careplus_password :string +# careplus_staff_code :string +# careplus_staff_type :string +# careplus_username :string +# careplus_venue_code :string +# days_before_consent_reminders :integer default(7), not null +# days_before_consent_requests :integer default(21), not null +# email :string +# name :text not null +# national_reporting_cut_off_date :date +# phone :string +# phone_instructions :string +# privacy_notice_url :string +# privacy_policy_url :string +# programme_types :enum not null, is an Array +# type :integer not null +# workgroup :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organisation_id :bigint not null +# reply_to_id :uuid # # Indexes # @@ -94,6 +95,7 @@ end trait :with_careplus_enabled do + careplus_automated_reports_enabled_at { Time.current } careplus_namespace { "MOCK" } careplus_staff_code { "LW5PM" } careplus_staff_type { "IN" } diff --git a/spec/features/cli_reports_send_to_careplus_spec.rb b/spec/features/cli_reports_send_to_careplus_spec.rb index 92c9636ed9..ffb7301c69 100644 --- a/spec/features/cli_reports_send_to_careplus_spec.rb +++ b/spec/features/cli_reports_send_to_careplus_spec.rb @@ -115,7 +115,7 @@ ) then_the_error_output_includes( - "does not have CarePlus credentials configured" + "needs the CarePlus username, password, and namespace configured to send reports." ) and_no_request_was_made end diff --git a/spec/jobs/commit_patient_changesets_job_spec.rb b/spec/jobs/commit_patient_changesets_job_spec.rb index 0ad569fbf4..292caca128 100644 --- a/spec/jobs/commit_patient_changesets_job_spec.rb +++ b/spec/jobs/commit_patient_changesets_job_spec.rb @@ -130,6 +130,19 @@ expect(gae.parents).not_to be_empty end + it "sets nhs_number_first_added_at for newly imported patients with NHS numbers" do + freeze_time do + perform_job + + timestamps = + Patient.where(nhs_number: %w[9990000018 9990000026 9990000034]).pluck( + :nhs_number_first_added_at + ) + + expect(timestamps).to all(eq(Time.current)) + end + end + it "stores statistics on the import" do # stree-ignore expect { @@ -199,6 +212,38 @@ end end + context "with an existing patient matching the import except for the NHS number" do + let!(:patient) do + create( + :patient, + given_name: "Jimmy", + preferred_given_name: "Jim", + family_name: "Smith", + date_of_birth: Date.new(2010, 1, 2), + nhs_number: nil, + nhs_number_first_added_at: nil, + address_line_1: "10 Downing Street", + address_town: "London", + address_postcode: "SW1A 1AA", + registration: "ABC", + school: location, + parents: [] + ) + end + + before { PatientChangeset.all.map(&:calculate_review_data!) } + + it "sets nhs_number_first_added_at when an NHS number is auto-accepted onto an existing patient" do + freeze_time do + expect { perform_job }.to change { + patient.reload.nhs_number_first_added_at + }.from(nil).to(Time.current) + end + + expect(patient.reload.nhs_number).to eq("9990000026") + end + end + context "with an existing patient matching the name but a different case" do before do create( diff --git a/spec/lib/careplus/automated_report_sender_spec.rb b/spec/lib/careplus/automated_report_sender_spec.rb index 771a014671..39aa6cd5b4 100644 --- a/spec/lib/careplus/automated_report_sender_spec.rb +++ b/spec/lib/careplus/automated_report_sender_spec.rb @@ -4,7 +4,12 @@ subject(:call) { described_class.call(team_id: team.id) } let(:team) do - create(:team, :with_careplus_enabled, programmes: Programme.all) + create( + :team, + :with_careplus_enabled, + programmes: Programme.all, + careplus_automated_reports_enabled_at: Time.zone.local(2025, 8, 28, 10) + ) end let(:programme) { Programme.hpv } let(:session) { create(:session, team:, programmes: [programme]) } @@ -167,4 +172,87 @@ expect { call }.not_to change(CareplusReport, :count) end end + + context "when a patient gains an NHS number yesterday" do + it "includes records created after the integration was enabled" do + patient = + create( + :patient, + session:, + nhs_number_first_added_at: Time.zone.local(2025, 8, 31, 9) + ) + record = + create( + :vaccination_record, + patient:, + session:, + programme:, + performed_at: Date.new(2025, 8, 29), + created_at: Time.zone.local(2025, 8, 29, 12), + updated_at: Time.zone.local(2025, 8, 29, 12) + ) + + expect { call }.to change(CareplusReport, :count).by(1) + + expect(CareplusReport.last.vaccination_records).to contain_exactly(record) + end + + it "does not include records created before the integration was enabled" do + team.update!( + careplus_automated_reports_enabled_at: Time.zone.local(2025, 8, 30, 10) + ) + + patient = + create( + :patient, + session:, + nhs_number_first_added_at: Time.zone.local(2025, 8, 31, 9) + ) + create( + :vaccination_record, + patient:, + session:, + programme:, + performed_at: Date.new(2025, 8, 29), + created_at: Time.zone.local(2025, 8, 29, 12), + updated_at: Time.zone.local(2025, 8, 29, 12) + ) + + expect { call }.not_to change(CareplusReport, :count) + end + + it "deduplicates records that also changed yesterday" do + patient = + create( + :patient, + session:, + nhs_number_first_added_at: Time.zone.local(2025, 8, 31, 9) + ) + record = + create( + :vaccination_record, + patient:, + session:, + programme:, + performed_at: yesterday, + created_at: Time.zone.local(2025, 8, 29, 12), + updated_at: Time.zone.local(2025, 8, 31, 12) + ) + + expect { call }.to change(CareplusReport, :count).by(1).and( + change(CareplusReportVaccinationRecord, :count).by(1) + ) + + expect(CareplusReport.last.vaccination_records).to contain_exactly(record) + expect(WebMock).to have_requested(:post, endpoint).once + end + end + + context "when CarePlus is configured but not manually enabled" do + before { team.update!(careplus_automated_reports_enabled_at: nil) } + + it "does nothing" do + expect { call }.not_to change(CareplusReport, :count) + end + end end diff --git a/spec/lib/reports/automated_careplus_exporter_spec.rb b/spec/lib/reports/automated_careplus_exporter_spec.rb index 78b020aa8d..bb7cfffd5e 100644 --- a/spec/lib/reports/automated_careplus_exporter_spec.rb +++ b/spec/lib/reports/automated_careplus_exporter_spec.rb @@ -28,29 +28,70 @@ described_class.call(team:, academic_year:, start_date:, end_date:) end - it "delegates vaccination_records_scope to CareplusExporter with the correct parameters" do - team = create(:team) - academic_year = AcademicYear.current - start_date = 1.month.ago.to_date - end_date = Date.current + describe ".vaccination_records_scope" do + let(:programme) { Programme.hpv } + let(:export_date) { Date.new(2025, 8, 31) } + let(:team) do + create( + :team, + :with_careplus_enabled, + programmes: [programme], + careplus_automated_reports_enabled_at: Time.zone.local(2025, 8, 28, 10) + ) + end + let(:session) { create(:session, team:, programmes: [programme]) } - expect(Reports::CareplusExporter).to receive( - :vaccination_records_scope - ).with( - team:, - programmes: team.programmes, - academic_year:, - start_date:, - end_date:, - include_missing_nhs_number: false - ) + it "includes records changed in the export window" do + included_record = + create( + :vaccination_record, + patient: create(:patient, session:), + session:, + programme:, + performed_at: export_date, + created_at: export_date, + updated_at: export_date + ) - described_class.vaccination_records_scope( - team:, - academic_year:, - start_date:, - end_date: - ) + scope = + described_class.vaccination_records_scope( + team:, + academic_year: export_date.academic_year, + start_date: export_date, + end_date: export_date + ) + + expect(scope).to include(included_record) + end + + it "includes older records for patients who first had an NHS number added in the export window" do + patient = + create( + :patient, + session:, + nhs_number_first_added_at: Time.zone.local(2025, 8, 31, 9) + ) + included_record = + create( + :vaccination_record, + patient:, + session:, + programme:, + performed_at: Date.new(2025, 8, 29), + created_at: Time.zone.local(2025, 8, 29, 12), + updated_at: Time.zone.local(2025, 8, 29, 12) + ) + + scope = + described_class.vaccination_records_scope( + team:, + academic_year: export_date.academic_year, + start_date: export_date, + end_date: export_date + ) + + expect(scope).to include(included_record) + end end it "passes the correct parameters to CareplusExporter.from_records" do diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index e9644acba1..d36e2ae079 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -276,6 +276,19 @@ .and change(immunisation_import, :new_record_count).to(11) end + it "sets nhs_number_first_added_at for imported patients with NHS numbers" do + immunisation_import.process! + + timestamps = + immunisation_import + .patients + .where.not(nhs_number: nil) + .pluck(:nhs_number_first_added_at) + + expect(timestamps).not_to be_empty + expect(timestamps).to all(eq(Time.current)) + end + it "ignores and counts duplicate records" do duplicate_import.parse_rows! duplicate_import.process! diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index b15503ae49..a67a678562 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -22,6 +22,7 @@ # invalidated_at :datetime # local_authority_mhclg_code :string # nhs_number :string +# nhs_number_first_added_at :datetime # pending_changes :jsonb not null # preferred_family_name :string # preferred_given_name :string @@ -47,6 +48,7 @@ # index_patients_on_names_family_first (family_name,given_name) # index_patients_on_names_given_first (given_name,family_name) # index_patients_on_nhs_number (nhs_number) UNIQUE +# index_patients_on_nhs_number_first_added_at (nhs_number_first_added_at) # index_patients_on_pending_changes_not_empty (id) WHERE (pending_changes <> '{}'::jsonb) # index_patients_on_school_id (school_id) # @@ -1101,6 +1103,20 @@ end end + it "sets nhs_number_first_added_at when the NHS number was assigned before save" do + patient = + create(:patient, nhs_number: nil, nhs_number_first_added_at: nil) + patient.nhs_number = "9449310475" + pds_patient = PDS::Patient.new(nhs_number: "9449310475") + + freeze_time do + expect { patient.update_from_pds!(pds_patient) }.to change( + patient, + :nhs_number_first_added_at + ).from(nil).to(Time.current) + end + end + context "when the NHS number doesn't match" do let(:pds_patient) { PDS::Patient.new(nhs_number: "abc") } @@ -1307,6 +1323,38 @@ end end + describe "NHS number first added timestamp" do + it "sets nhs_number_first_added_at when an NHS number is first added" do + patient = + create(:patient, nhs_number: nil, nhs_number_first_added_at: nil) + + freeze_time do + expect { patient.update!(nhs_number: "9449310475") }.to change { + patient.reload.nhs_number_first_added_at + }.from(nil).to(Time.current) + end + end + + it "does not clear nhs_number_first_added_at when an NHS number is removed" do + patient = create(:patient) + + expect { patient.update!(nhs_number: nil) }.not_to( + change { patient.reload.nhs_number_first_added_at } + ) + end + + it "does not overwrite nhs_number_first_added_at when an NHS number is re-added" do + patient = create(:patient) + first_added_at = patient.nhs_number_first_added_at + + patient.update!(nhs_number: nil) + + expect { patient.update!(nhs_number: "9449310475") }.not_to change { + patient.reload.nhs_number_first_added_at + }.from(first_added_at) + end + end + describe "#should_search_vaccinations_from_nhs_immunisations_api?" do subject(:should_search_vaccinations_from_nhs_immunisations_api?) do patient.send(:should_search_vaccinations_from_nhs_immunisations_api?) @@ -1380,6 +1428,24 @@ end end + describe "#apply_pending_changes!" do + let(:patient) do + create(:patient, nhs_number: nil, nhs_number_first_added_at: nil) + end + + before do + patient.update!(pending_changes: { "nhs_number" => "9449310475" }) + end + + it "sets nhs_number_first_added_at when a pending NHS number is applied" do + freeze_time do + expect { patient.apply_pending_changes! }.to change { + patient.reload.nhs_number_first_added_at + }.from(nil).to(Time.current) + end + end + end + describe "#dup_for_pending_changes" do subject(:new_patient) { old_patient.dup_for_pending_changes } diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index 15cb91102f..8b28b3b915 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -4,29 +4,30 @@ # # Table name: teams # -# id :bigint not null, primary key -# careplus_namespace :string -# careplus_password :string -# careplus_staff_code :string -# careplus_staff_type :string -# careplus_username :string -# careplus_venue_code :string -# days_before_consent_reminders :integer default(7), not null -# days_before_consent_requests :integer default(21), not null -# email :string -# name :text not null -# national_reporting_cut_off_date :date -# phone :string -# phone_instructions :string -# privacy_notice_url :string -# privacy_policy_url :string -# programme_types :enum not null, is an Array -# type :integer not null -# workgroup :string not null -# created_at :datetime not null -# updated_at :datetime not null -# organisation_id :bigint not null -# reply_to_id :uuid +# id :bigint not null, primary key +# careplus_automated_reports_enabled_at :datetime +# careplus_namespace :string +# careplus_password :string +# careplus_staff_code :string +# careplus_staff_type :string +# careplus_username :string +# careplus_venue_code :string +# days_before_consent_reminders :integer default(7), not null +# days_before_consent_requests :integer default(21), not null +# email :string +# name :text not null +# national_reporting_cut_off_date :date +# phone :string +# phone_instructions :string +# privacy_notice_url :string +# privacy_policy_url :string +# programme_types :enum not null, is an Array +# type :integer not null +# workgroup :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organisation_id :bigint not null +# reply_to_id :uuid # # Indexes # @@ -175,6 +176,11 @@ create(:team, :with_careplus_enabled, careplus_username: nil) create(:team, :with_careplus_enabled, careplus_password: nil) create(:team, :with_careplus_enabled, careplus_namespace: nil) + create( + :team, + :with_careplus_enabled, + careplus_automated_reports_enabled_at: nil + ) create( :team, careplus_username: "careplus_user", @@ -208,6 +214,24 @@ end end + describe "#has_careplus_credentials?" do + subject(:has_careplus_credentials?) { team.has_careplus_credentials? } + + context "when CarePlus credentials are configured" do + let(:team) { create(:team, :with_careplus_enabled) } + + it { should be(true) } + end + + context "when CarePlus credentials are missing" do + let(:team) do + create(:team, :with_careplus_enabled, careplus_username: nil) + end + + it { should be(false) } + end + end + describe "#eligible_for_automated_careplus_reports?" do subject(:eligible_for_automated_careplus_reports?) do team.eligible_for_automated_careplus_reports? @@ -227,6 +251,18 @@ it { should be(false) } end + context "when CarePlus integration has not been enabled yet" do + let(:team) do + create( + :team, + :with_careplus_enabled, + careplus_automated_reports_enabled_at: nil + ) + end + + it { should be(false) } + end + context "when CarePlus export fields are missing" do let(:team) do create(