Skip to content

Commit 4aceedb

Browse files
authored
Merge pull request #6602 from NHSDigital/add-sidekiq-job-for-careplus-export
Add sidekiq job for careplus export
2 parents ec002c0 + 465091e commit 4aceedb

7 files changed

Lines changed: 381 additions & 0 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
class EnqueueAutomatedCareplusReportsJob
4+
include Sidekiq::Job
5+
6+
sidekiq_options queue: :careplus
7+
8+
def perform
9+
Team.eligible_for_automated_careplus_reports.ids.each do |team_id|
10+
SendAutomatedCareplusReportsJob.perform_async(team_id)
11+
end
12+
end
13+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
class SendAutomatedCareplusReportsJob
4+
include Sidekiq::Job
5+
6+
sidekiq_options queue: :careplus, lock: :until_executed
7+
8+
def perform(team_id)
9+
Careplus::AutomatedReportSender.call(team_id:)
10+
end
11+
end
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# frozen_string_literal: true
2+
3+
class Careplus::AutomatedReportSender
4+
FailedResponseError = Class.new(StandardError)
5+
BATCH_SIZE = 10_000
6+
7+
def self.call(...) = new(...).call
8+
9+
def initialize(team_id:)
10+
@team = Team.find(team_id)
11+
end
12+
13+
def call
14+
return unless team.eligible_for_automated_careplus_reports?
15+
16+
export_date = Date.yesterday
17+
academic_year = export_date.academic_year
18+
records_scope =
19+
Reports::AutomatedCareplusExporter.vaccination_records_scope(
20+
team:,
21+
academic_year:,
22+
start_date: export_date,
23+
end_date: export_date
24+
)
25+
26+
records_scope
27+
.unscope(:order)
28+
.in_batches(of: BATCH_SIZE) do |batch_scope|
29+
batch_records = records_scope.where(id: batch_scope.select(:id))
30+
next if batch_records.none?
31+
32+
send_batch!(
33+
vaccination_records: batch_records,
34+
academic_year:,
35+
date: export_date
36+
)
37+
end
38+
end
39+
40+
private
41+
42+
attr_reader :team
43+
44+
def send_batch!(vaccination_records:, academic_year:, date:)
45+
csv =
46+
Reports::AutomatedCareplusExporter.from_records(
47+
vaccination_records:,
48+
team:,
49+
academic_year:
50+
)
51+
programme_types =
52+
vaccination_records.unscope(:order).distinct.pluck(:programme_type)
53+
careplus_report =
54+
create_export!(academic_year:, csv:, date:, programme_types:)
55+
56+
attach_records!(careplus_report:, vaccination_records:)
57+
58+
record_send_attempt!(careplus_report:)
59+
60+
response =
61+
Careplus::Client.send_csv(
62+
username: team.careplus_username,
63+
password: team.careplus_password,
64+
namespace: team.careplus_namespace,
65+
payload: csv
66+
)
67+
68+
unless response.is_a?(Net::HTTPSuccess)
69+
careplus_report.update!(status: :failed)
70+
71+
raise FailedResponseError,
72+
"CarePlus request failed with HTTP #{response.code}: #{response.message}"
73+
end
74+
75+
mark_as_sent!(careplus_report:)
76+
rescue StandardError
77+
careplus_report&.update!(status: :failed)
78+
raise
79+
end
80+
81+
def create_export!(academic_year:, csv:, date:, programme_types:)
82+
timestamp = Time.current
83+
84+
CareplusReport.create!(
85+
team:,
86+
academic_year:,
87+
date_from: date,
88+
date_to: date,
89+
programme_types:,
90+
scheduled_at: timestamp,
91+
status: :sending,
92+
csv_filename: csv_filename(date:, timestamp:),
93+
csv_data: csv
94+
)
95+
end
96+
97+
def attach_records!(careplus_report:, vaccination_records:)
98+
timestamp = Time.current
99+
100+
CareplusReportVaccinationRecord.insert_all!(
101+
vaccination_records.map do |record|
102+
{
103+
careplus_report_id: careplus_report.id,
104+
vaccination_record_id: record.id,
105+
change_type: 0,
106+
created_at: timestamp,
107+
updated_at: timestamp
108+
}
109+
end
110+
)
111+
end
112+
113+
def record_send_attempt!(careplus_report:)
114+
careplus_report.update!(sent_at: Time.current)
115+
end
116+
117+
def mark_as_sent!(careplus_report:)
118+
careplus_report.update!(status: :sent)
119+
end
120+
121+
def csv_filename(date:, timestamp:)
122+
"#{
123+
[
124+
"automated-careplus",
125+
team.workgroup.parameterize,
126+
date.iso8601,
127+
timestamp.strftime("%H%M%S%6N")
128+
].join("-")
129+
}.csv"
130+
end
131+
end

config/sidekiq.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- immunisations_api_sync
1313
- immunisations_api_search
1414
- third_party_data_imports
15+
- careplus
1516
- metrics
1617
- default
1718

@@ -87,3 +88,7 @@
8788
cron: "0 13,16,19 * * *"
8889
class: SendVaccinationConfirmationsJob
8990
description: Send vaccination confirmation emails to parents
91+
automated_careplus_reports:
92+
cron: "30 2 * * *"
93+
class: EnqueueAutomatedCareplusReportsJob
94+
description: Enqueue automated CarePlus reports for teams with CarePlus configured
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
describe EnqueueAutomatedCareplusReportsJob do
4+
subject(:perform) { described_class.new.perform }
5+
6+
let(:eligible_team) do
7+
create(:team, :with_careplus_enabled, programmes: Programme.all)
8+
end
9+
let(:team_without_credentials) do
10+
create(
11+
:team,
12+
:with_careplus_enabled,
13+
careplus_username: nil,
14+
programmes: Programme.all
15+
)
16+
end
17+
let(:team_without_careplus_report_fields) do
18+
create(
19+
:team,
20+
careplus_username: "careplus_user",
21+
careplus_password: "careplus_password",
22+
careplus_namespace: "MOCK",
23+
programmes: Programme.all
24+
)
25+
end
26+
27+
it "enqueues a send job for each team with CarePlus enabled and credentials configured" do
28+
eligible_team
29+
team_without_credentials
30+
team_without_careplus_report_fields
31+
32+
expect { perform }.to enqueue_sidekiq_job(
33+
SendAutomatedCareplusReportsJob
34+
).once.with(eligible_team.id)
35+
end
36+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
describe SendAutomatedCareplusReportsJob do
4+
describe "#perform" do
5+
it "delegates to Careplus::AutomatedReportSender" do
6+
team = create(:team, :with_careplus_enabled, programmes: Programme.all)
7+
8+
expect(Careplus::AutomatedReportSender).to receive(:call).with(
9+
team_id: team.id
10+
)
11+
12+
described_class.new.perform(team.id)
13+
end
14+
end
15+
end
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# frozen_string_literal: true
2+
3+
describe Careplus::AutomatedReportSender do
4+
subject(:call) { described_class.call(team_id: team.id) }
5+
6+
let(:team) do
7+
create(:team, :with_careplus_enabled, programmes: Programme.all)
8+
end
9+
let(:programme) { Programme.hpv }
10+
let(:session) { create(:session, team:, programmes: [programme]) }
11+
let(:endpoint) do
12+
"#{Settings.careplus.base_url}/#{team.careplus_namespace}/soap.SchImms.cls"
13+
end
14+
let(:response_status) { 200 }
15+
let(:response_body) { "<result>OK</result>" }
16+
let(:yesterday) { Date.new(2025, 8, 31) }
17+
let(:yesterday_academic_year) { yesterday.academic_year }
18+
19+
before do
20+
stub_request(:post, endpoint).to_return(
21+
status: response_status,
22+
body: response_body
23+
)
24+
end
25+
26+
around { |example| travel_to(Date.new(2025, 9, 1)) { example.run } }
27+
28+
it "creates, sends, and stores a sent report with linked vaccination records" do
29+
record =
30+
create(
31+
:vaccination_record,
32+
session:,
33+
programme:,
34+
performed_at: yesterday,
35+
created_at: yesterday,
36+
updated_at: yesterday
37+
)
38+
39+
expect { call }.to change(CareplusReport, :count).by(1).and(
40+
change(CareplusReportVaccinationRecord, :count).by(1)
41+
)
42+
43+
report = CareplusReport.last
44+
45+
expect(report).to have_attributes(
46+
team:,
47+
academic_year: yesterday_academic_year,
48+
date_from: yesterday,
49+
date_to: yesterday,
50+
status: "sent"
51+
)
52+
expect(report.sent_at).to be_present
53+
expect(report.vaccination_records).to contain_exactly(record)
54+
expect(WebMock).to have_requested(:post, endpoint).once
55+
end
56+
57+
it "uses yesterday and its academic year for the automated export scope" do
58+
create(
59+
:vaccination_record,
60+
session:,
61+
programme:,
62+
performed_at: yesterday,
63+
created_at: yesterday,
64+
updated_at: yesterday
65+
)
66+
67+
expect(Reports::AutomatedCareplusExporter).to receive(
68+
:vaccination_records_scope
69+
).with(
70+
team:,
71+
academic_year: yesterday_academic_year,
72+
start_date: yesterday,
73+
end_date: yesterday
74+
).and_call_original
75+
76+
call
77+
end
78+
79+
it "splits yesterday's scope into batches of 10000 records" do
80+
stub_const("#{described_class}::BATCH_SIZE", 2)
81+
records =
82+
Array.new(3) do
83+
create(
84+
:vaccination_record,
85+
session:,
86+
programme:,
87+
performed_at: yesterday,
88+
created_at: yesterday,
89+
updated_at: yesterday
90+
)
91+
end
92+
93+
expect { call }.to change(CareplusReport, :count).by(2).and(
94+
change(CareplusReportVaccinationRecord, :count).by(3)
95+
)
96+
97+
expect(WebMock).to have_requested(:post, endpoint).twice
98+
expect(
99+
CareplusReport
100+
.order(:id)
101+
.map { |report| report.vaccination_records.count }
102+
).to eq([2, 1])
103+
expect(
104+
CareplusReport
105+
.joins(:vaccination_records)
106+
.distinct
107+
.flat_map(&:vaccination_records)
108+
).to match_array(records)
109+
end
110+
111+
context "when CarePlus returns a failure response" do
112+
let(:response_status) { 500 }
113+
let(:response_body) { "<fault>Error</fault>" }
114+
115+
it "marks the report as failed, keeps linked vaccination records, and raises for retry" do
116+
record =
117+
create(
118+
:vaccination_record,
119+
session:,
120+
programme:,
121+
performed_at: yesterday,
122+
created_at: yesterday,
123+
updated_at: yesterday
124+
)
125+
126+
expect { call }.to raise_error(
127+
described_class::FailedResponseError,
128+
"CarePlus request failed with HTTP 500: "
129+
).and change(CareplusReport, :count).by(1).and(
130+
change(CareplusReportVaccinationRecord, :count).by(1)
131+
)
132+
133+
report = CareplusReport.last
134+
expect(report).to have_attributes(status: "failed")
135+
expect(report.sent_at).to be_present
136+
expect(report.vaccination_records).to contain_exactly(record)
137+
end
138+
end
139+
140+
context "when CarePlus raises an error" do
141+
before { stub_request(:post, endpoint).to_raise(Timeout::Error) }
142+
143+
it "marks the report as failed and keeps linked vaccination records before re-raising" do
144+
record =
145+
create(
146+
:vaccination_record,
147+
session:,
148+
programme:,
149+
performed_at: yesterday,
150+
created_at: yesterday,
151+
updated_at: yesterday
152+
)
153+
154+
expect { call }.to raise_error(Timeout::Error)
155+
156+
report = CareplusReport.last
157+
expect(report).to have_attributes(status: "failed")
158+
expect(report.sent_at).to be_present
159+
expect(report.vaccination_records).to contain_exactly(record)
160+
end
161+
end
162+
163+
context "when the team is no longer eligible for automated reports" do
164+
before { team.update!(careplus_username: nil) }
165+
166+
it "does nothing" do
167+
expect { call }.not_to change(CareplusReport, :count)
168+
end
169+
end
170+
end

0 commit comments

Comments
 (0)