diff --git a/app/controllers/reports/annual_reports_controller.rb b/app/controllers/reports/annual_reports_controller.rb index 76bc4aff23..774b21e301 100644 --- a/app/controllers/reports/annual_reports_controller.rb +++ b/app/controllers/reports/annual_reports_controller.rb @@ -4,11 +4,11 @@ class Reports::AnnualReportsController < ApplicationController def index # 2813_update_annual_report -- changed to earliest_reporting_year # so that we can do system tests and staging - foundation_year = current_organization.earliest_reporting_year + @foundation_year = current_organization.earliest_reporting_year @actual_year = Time.current.year - @years = (foundation_year...@actual_year).to_a + @years = (@foundation_year...@actual_year).to_a @month_remaining_to_report = 12 - Time.current.month end @@ -32,12 +32,35 @@ def recalculate redirect_to reports_annual_report_path(year), notice: "Recalculated annual report!" end + def range + year_start = range_params[:year_start].to_i + year_end = range_params[:year_end].to_i + + if year_end < year_start + flash[:error] = "End year must be greater than or equal to start year." + redirect_to reports_annual_reports_path and return + end + + reports = Reports::AnnualSurveyReportService.new(organization: current_organization, year_start: year_start, year_end: year_end).call + + respond_to do |format| + format.csv do + send_data Exports::ExportReportCSVService.new(reports:).generate_csv(range: true), + filename: "NdbnAnnuals-#{year_start}-#{year_end}.csv" + end + end + end + private def year_param params.require(:year) end + def range_params + params.permit(:year_start, :year_end) + end + def validate_show_params not_found! unless year_param.to_i.positive? end diff --git a/app/services/exports/export_report_csv_service.rb b/app/services/exports/export_report_csv_service.rb index ad6c51da99..1707277d2d 100644 --- a/app/services/exports/export_report_csv_service.rb +++ b/app/services/exports/export_report_csv_service.rb @@ -7,8 +7,8 @@ def initialize(reports:) @reports = reports end - def generate_csv - csv_data = generate_csv_data + def generate_csv(range: false) + csv_data = range ? generate_range_csv_data : generate_csv_data ::CSV.generate(headers: true) do |csv| csv_data.each { |row| csv << row } @@ -31,5 +31,36 @@ def generate_csv_data csv_data end + + def generate_range_csv_data + return [] if @reports.empty? + + # Ordered unique headers (in first-seen order) + header_index = {} + + # Cache each year's flattened entries so we don't re-walk twice + yearly_entries = @reports.map do |report| + entries = {} + + report.all_reports.each do |section| + section.fetch("entries", {}).each do |key, value| + header_index[key] ||= true + entries[key] = value + end + end + + {year: report["year"], entries: entries} + end + + headers = header_index.keys + csv_data = [] + csv_data << ["Year"] + headers + + yearly_entries.each do |row| + csv_data << [row[:year]] + headers.map { |h| row[:entries][h] } + end + + csv_data + end end end diff --git a/app/services/reports/annual_survey_report_service.rb b/app/services/reports/annual_survey_report_service.rb new file mode 100644 index 0000000000..e58e9b98af --- /dev/null +++ b/app/services/reports/annual_survey_report_service.rb @@ -0,0 +1,18 @@ +module Reports + class AnnualSurveyReportService + def initialize(organization:, year_start:, year_end:) + @organization = organization + @year_start = year_start + @year_end = year_end + end + + def call + (@year_start..@year_end).map do |year| + Reports.retrieve_report(organization: @organization, year: year, recalculate: true) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error("Failed to retrieve annual report for year #{year}: #{e.message}") + nil + end.compact + end + end +end diff --git a/app/views/reports/annual_reports/index.html.erb b/app/views/reports/annual_reports/index.html.erb index fbfffdfdb3..882788a721 100644 --- a/app/views/reports/annual_reports/index.html.erb +++ b/app/views/reports/annual_reports/index.html.erb @@ -27,6 +27,15 @@
Reports are available at the end of every year.
<%= "#{@actual_year} (available in #{pluralize(@month_remaining_to_report, 'month')})" %> +
+ <%= + download_button_to( + range_reports_annual_reports_path(year_start: @foundation_year, year_end: (@actual_year - 1), format: :csv), + text: "Export Yearly Reports" + ) + %> + This will recalculate all the reports, and may take some time. +
diff --git a/config/routes.rb b/config/routes.rb index 74bf981a79..b37f316d16 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -109,6 +109,11 @@ def set_up_flipper namespace :reports do resources :annual_reports, only: [:index, :show], param: :year do post :recalculate, on: :member + get 'range/:year_start/:year_end', + to: 'annual_reports#range', + on: :collection, + as: 'range', + constraints: { year_start: /\d{4}/, year_end: /\d{4}/ } end get :donations_summary get :manufacturer_donations_summary diff --git a/docs/user_guide/bank/images/reports/reports-anual-survey-yearly-export.png b/docs/user_guide/bank/images/reports/reports-anual-survey-yearly-export.png new file mode 100644 index 0000000000..86f503f29f Binary files /dev/null and b/docs/user_guide/bank/images/reports/reports-anual-survey-yearly-export.png differ diff --git a/docs/user_guide/bank/reports_annual_survey.md b/docs/user_guide/bank/reports_annual_survey.md index 734de00602..804e028cf3 100644 --- a/docs/user_guide/bank/reports_annual_survey.md +++ b/docs/user_guide/bank/reports_annual_survey.md @@ -2,7 +2,15 @@ The annual survey contains information useful for completing the NDBN or Alliance for Period Supplies annual survey, but also for grant writing. Each year's annual survey becomes available January 1 of the following year. -## How to get the report +## How to export all annual survey reports + +1. Click on “Reports” +2. Click on “Annual Survey” +3. Click on "Export Yearly Reports + +![Annual Report Yearly](images/reports/reports-anual-survey-yearly-export.png) + +## How to export individual annual survey reports Click on "Reports", then "Annual Survey" in the left-hand menu. Then click on the year of the report you wish to view. ![Navigation to annual report](images/reports/reports_annual_survey_1.png) diff --git a/spec/requests/reports/annual_reports_requests_spec.rb b/spec/requests/reports/annual_reports_requests_spec.rb index c6229428b5..d7166b114b 100644 --- a/spec/requests/reports/annual_reports_requests_spec.rb +++ b/spec/requests/reports/annual_reports_requests_spec.rb @@ -1,5 +1,5 @@ RSpec.describe "Annual Reports", type: :request do - let(:organization) { create(:organization) } + let(:organization) { create(:organization, created_at: Time.zone.local(2006, 1, 1)) } let(:user) { create(:user, organization: organization) } let(:organization_admin) { create(:organization_admin, organization: organization) } @@ -56,6 +56,67 @@ end end + describe "GET /range" do + context "with valid year range" do + it "returns http success and generates a CSV with the correct year ranges" do + get range_reports_annual_reports_path(year_start: 2016, year_end: 2018, format: :csv) + + expect(response).to have_http_status(:success) + expect(response.body).to include("2016") + expect(response.body).to include("2017") + expect(response.body).to include("2018") + end + + it "returns correct data given columns are not at parity between years" do + # Some years may have columns that do not exist in other years, simulate the + # situation with "New Field" that only exists in 2017, but not 2016 + shared_entries = { "Total Distributed" => 100, "Total Donors" => 5 } + extra_entries = { "Total Distributed" => 200, "Total Donors" => 8, "New Field" => 42 } + + report_2016 = instance_double(AnnualReport, + "[]": nil, + all_reports: [{ "entries" => shared_entries }]) + report_2017 = instance_double(AnnualReport, + "[]": nil, + all_reports: [{ "entries" => extra_entries }]) + + allow(report_2016).to receive(:[]).with("year").and_return(2016) + allow(report_2017).to receive(:[]).with("year").and_return(2017) + + allow(Reports).to receive(:retrieve_report) + .with(hash_including(year: 2016)).and_return(report_2016) + allow(Reports).to receive(:retrieve_report) + .with(hash_including(year: 2017)).and_return(report_2017) + + get range_reports_annual_reports_path(year_start: 2016, year_end: 2017, format: :csv) + + csv = CSV.parse(response.body, headers: true) + + expect(csv&.headers).to include("Year", "Total Distributed", "Total Donors", "New Field") + + row_2016 = csv&.find { |r| r["Year"] == "2016" } + row_2017 = csv&.find { |r| r["Year"] == "2017" } + + expect(row_2016["New Field"]).to be_nil + expect(row_2017["New Field"]).to eq("42") + expect(row_2017["Total Distributed"]).to eq("200") + end + end + + context "invalid year ranges given" do + it "should raise a URL error" do + expect { get range_reports_annual_reports_path(year_start: 'test', year_end: 'test', format: :csv) } + .to raise_error(ActionController::UrlGenerationError) + end + + it "should redirect and show an error message if end year is less than start year" do + get range_reports_annual_reports_path(year_start: 2018, year_end: 2016, format: :csv) + expect(response).to have_http_status(:found) + expect(flash[:error]).to eq("End year must be greater than or equal to start year.") + end + end + end + describe 'POST /recalculate' do it "recalculates new reports" do expect(AnnualReport.count).to eq(0) diff --git a/spec/services/reports/annual_survey_report_service_spec.rb b/spec/services/reports/annual_survey_report_service_spec.rb new file mode 100644 index 0000000000..c6746ba96e --- /dev/null +++ b/spec/services/reports/annual_survey_report_service_spec.rb @@ -0,0 +1,84 @@ +RSpec.describe Reports::AnnualSurveyReportService, type: :service do + let(:organization) { create(:organization) } + + subject(:service) do + described_class.new(organization: organization, year_start: year_start, year_end: year_end) + end + + describe "#call" do + context "with a valid year range" do + let(:year_start) { 2020 } + let(:year_end) { 2022 } + + let(:report_2020) { instance_double(AnnualReport) } + let(:report_2021) { instance_double(AnnualReport) } + let(:report_2022) { instance_double(AnnualReport) } + + before do + allow(Reports).to receive(:retrieve_report) + .with(hash_including(year: 2020)).and_return(report_2020) + allow(Reports).to receive(:retrieve_report) + .with(hash_including(year: 2021)).and_return(report_2021) + allow(Reports).to receive(:retrieve_report) + .with(hash_including(year: 2022)).and_return(report_2022) + end + + it "returns one report per year in the range" do + expect(service.call).to eq([report_2020, report_2021, report_2022]) + end + + it "calls retrieve_report with recalculate: true for each year" do + service.call + expect(Reports).to have_received(:retrieve_report) + .with(hash_including(organization: organization, year: 2020, recalculate: true)) + expect(Reports).to have_received(:retrieve_report) + .with(hash_including(organization: organization, year: 2021, recalculate: true)) + expect(Reports).to have_received(:retrieve_report) + .with(hash_including(organization: organization, year: 2022, recalculate: true)) + end + end + + context "with a single year range" do + let(:year_start) { 2021 } + let(:year_end) { 2021 } + + let(:report_2021) { instance_double(AnnualReport) } + + before do + allow(Reports).to receive(:retrieve_report) + .with(hash_including(year: 2021)).and_return(report_2021) + end + + it "returns a single report" do + expect(service.call).to eq([report_2021]) + end + end + + context "when a year's report raises ActiveRecord::RecordInvalid" do + let(:year_start) { 2020 } + let(:year_end) { 2022 } + + let(:report_2020) { instance_double(AnnualReport) } + let(:report_2022) { instance_double(AnnualReport) } + + before do + allow(Reports).to receive(:retrieve_report) + .with(hash_including(year: 2020)).and_return(report_2020) + allow(Reports).to receive(:retrieve_report) + .with(hash_including(year: 2021)).and_raise(ActiveRecord::RecordInvalid) + allow(Reports).to receive(:retrieve_report) + .with(hash_including(year: 2022)).and_return(report_2022) + end + + it "skips the failed year and returns the remaining reports" do + expect(service.call).to eq([report_2020, report_2022]) + end + + it "logs the error" do + allow(Rails.logger).to receive(:error) + service.call + expect(Rails.logger).to have_received(:error).with(/Failed to retrieve annual report for year 2021/) + end + end + end +end