diff --git a/app/services/exports/export_donations_csv_service.rb b/app/services/exports/export_donations_csv_service.rb index 9ca5bcb73f..8b5629f538 100644 --- a/app/services/exports/export_donations_csv_service.rb +++ b/app/services/exports/export_donations_csv_service.rb @@ -15,6 +15,13 @@ def initialize(donation_ids:, organization:) ).order(created_at: :asc) @organization = organization + item_header_names = @organization.items.select("DISTINCT ON (LOWER(name)) items.name").order("LOWER(name) ASC").map(&:name) + + @item_headers = if @organization.include_in_kind_values_in_exported_files + item_header_names.flat_map { |header| [header, "#{header} In-Kind Value"] } + else + item_header_names + end end def generate_csv @@ -42,7 +49,7 @@ def generate_csv_data def headers # Build the headers in the correct order - base_headers + item_headers + base_headers + @item_headers end # Returns a Hash of keys to indexes so that obtaining the index @@ -95,24 +102,6 @@ def base_headers base_table.keys end - def item_headers - return @item_headers if @item_headers - - item_names = Set.new - - donations.each do |donation| - donation.line_items.each do |line_item| - item_names.add(line_item.item.name) - end - end - - @item_headers = item_names.sort - - @item_headers = @item_headers.flat_map { |header| [header, "#{header} In-Kind Value"] } if @organization.include_in_kind_values_in_exported_files - - @item_headers - end - def build_row_data(donation) row = base_table.values.map { |closure| closure.call(donation) } row += make_item_quantity_and_value_slots @@ -128,7 +117,7 @@ def build_row_data(donation) end def make_item_quantity_and_value_slots - slots = Array.new(item_headers.size, 0) + slots = Array.new(@item_headers.size, 0) slots = slots.map.with_index { |value, index| index.odd? ? Money.new(0) : value } if @organization.include_in_kind_values_in_exported_files slots end diff --git a/spec/services/exports/export_donations_csv_service_spec.rb b/spec/services/exports/export_donations_csv_service_spec.rb index 89ec120140..fc21b6ff14 100644 --- a/spec/services/exports/export_donations_csv_service_spec.rb +++ b/spec/services/exports/export_donations_csv_service_spec.rb @@ -74,9 +74,11 @@ def source_name(donation) end context 'while "Include in-kind value in donation and distribution exports?" is set to yes' do - it 'should match the expected content with in-kind value of each item for the csv' do + before do allow(organization).to receive(:include_in_kind_values_in_exported_files).and_return(true) + end + it 'should match the expected content with in-kind value of each item for the csv' do csv = <<~CSV Source,Date,Details,Storage Location,Quantity of Items,Variety of Items,In-Kind Total,Comments,A Item,A Item In-Kind Value,B Item,B Item In-Kind Value,C Item,C Item In-Kind Value,Dupe Item,Dupe Item In-Kind Value,E Item,E Item In-Kind Value Product Drive,2025-01-01,#{source_name(donations[0])},Test Storage Location,15,2,94.0,It's a fine day for diapers.,7,70.00,0,0.00,0,0.00,8,24.00,0,0.00 @@ -86,6 +88,212 @@ def source_name(donation) CSV expect(subject).to eq(csv) end + + it 'should include inactive items in the export' do + inactive_item = create(:item, :inactive, name: "Inactive Item", organization: organization) + csv_data = described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data + + # Verify the inactive item appears in headers + expect(csv_data[0][18]).to eq(inactive_item.name) + expect(csv_data[0][19]).to eq("#{inactive_item.name} In-Kind Value") + + # Verify all rows have 0 quantity for the inactive item + csv_data[1..].each do |row| + expect(row[18]).to eq(0) + expect(row[19]).to eq(0) + end + end + + it 'should include items that are not in any donation' do + unused_item = create(:item, name: "Unused Item", organization: organization) + csv_data = described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data + + # Verify the unused item appears in headers + expect(csv_data[0][18]).to include(unused_item.name) + expect(csv_data[0][19]).to include("#{unused_item.name} In-Kind Value") + + # Verify all rows have 0 quantity for the unused item + csv_data[1..].each do |row| + expect(row[18]).to eq(0) + expect(row[19]).to eq(0) + end + end + end + end + + describe '#generate_csv_data' do + let(:organization) { create(:organization) } + let(:generated_csv_data) { described_class.new(donation_ids: donation_ids, organization: organization).generate_csv_data } + let(:donation_ids) { donations.map(&:id) } + let(:duplicate_item) { create(:item, organization: organization) } + let(:items_lists) do + [ + [ + [duplicate_item, 5], + [create(:item, organization: organization), 7], + [duplicate_item, 3] + ], + *(Array.new(3) do |i| + [[create( + :item, name: "item_#{i}", organization: organization + ), i + 1]] + end) + ] + end + + let(:base_headers) do + described_class.new(donation_ids: [], organization: organization).send(:base_headers) + end + + let(:item_names) { items_lists.flatten(1).map(&:first).map(&:name).sort.uniq } + + let(:donations) do + start_time = Time.current + + items_lists.each_with_index.map do |items, i| + donation = create( + :donation, + organization: organization, + donation_site: create( + :donation_site, name: "Space Needle #{i}", organization: organization + ), + issued_at: start_time + i.days, + comment: "This is the #{i}-th donation in the test." + ) + + items.each do |(item, quantity)| + donation.line_items << create( + :line_item, quantity: quantity, item: item + ) + end + + donation + end + end + + let(:expected_headers) do + [ + "Source", + "Date", + "Details", + "Storage Location", + "Quantity of Items", + "Variety of Items", + "In-Kind Total", + "Comments" + ] + expected_item_headers + end + + let(:total_item_quantities) do + template = item_names.index_with(0) + + items_lists.map do |items_list| + row = template.dup + items_list.each do |(item, quantity)| + row[item.name] += quantity + end + row.values + end + end + + let(:expected_item_headers) do + expect(item_names).not_to be_empty + + item_names + end + + it 'should match the expected content for the csv' do + expect(generated_csv_data[0]).to eq(expected_headers) + + donations.zip(total_item_quantities).each_with_index do |(donation, total_item_quantity), idx| + row = [ + donation.source, + donation.issued_at.strftime("%F"), + donation.details, + donation.storage_view, + donation.line_items.total, + total_item_quantity.count(&:positive?), + donation.in_kind_value_money, + donation.comment + ] + + row += total_item_quantity + + expect(generated_csv_data[idx + 1]).to eq(row) + end + end + + context 'when an organization\'s item exists but isn\'t in any donation' do + let!(:unused_item) { create(:item, name: "Unused Item", organization: organization) } + let!(:generated_csv_data) do + described_class.new(donation_ids: donations.map(&:id), organization: organization).generate_csv_data + end + + it 'should include the unused item as a column with 0 quantities' do + expect(generated_csv_data[0][13]).to eq(unused_item.name) + + donations.each_with_index do |_, idx| + row = generated_csv_data[idx + 1] + expect(row[13]).to eq(0) + end + end + end + + context 'when an organization\'s item is inactive' do + let!(:inactive_item) { create(:item, name: "Inactive Item", organization: organization, active: false) } + let!(:generated_csv_data) do + described_class.new(donation_ids: donations.map(&:id), organization: organization).generate_csv_data + end + + it 'should include the inactive item as a column with 0 quantities' do + expect(generated_csv_data[0][10]).to include(inactive_item.name) + + donations.each_with_index do |_, idx| + row = generated_csv_data[idx + 1] + expect(row[10]).to eq(0) + end + end + end + + context 'when generating CSV output' do + let(:generated_csv) { described_class.new(donation_ids: donation_ids, organization: organization).generate_csv } + + it 'returns a valid CSV string' do + expect(generated_csv).to be_a(String) + expect { CSV.parse(generated_csv) }.not_to raise_error + end + + it 'includes headers as first row' do + csv_rows = CSV.parse(generated_csv) + expect(csv_rows.first).to eq(expected_headers) + end + + it 'includes data for all donations' do + csv_rows = CSV.parse(generated_csv) + expect(csv_rows.count).to eq(donations.count + 1) # +1 for headers + end + end + + context 'when items have different cases' do + let(:item_names) { ["Zebra", "apple", "Banana"] } + let(:expected_order) { ["apple", "Banana", "Zebra"] } + let(:donation) { create(:donation, organization: organization) } + let(:case_sensitive_csv_data) do + # Create items in random order to ensure sort is working + item_names.shuffle.each do |name| + create(:item, name: name, organization: organization) + end + + described_class.new(donation_ids: [donation.id], organization: organization).generate_csv_data + end + + it 'should sort item columns case-insensitively, ASC' do + # Get just the item columns by removing the known base headers + item_columns = case_sensitive_csv_data[0] - base_headers + + # Check that the remaining columns match our expected case-insensitive sort + expect(item_columns).to eq(expected_order) + end end end end