diff --git a/app/controllers/storage_locations_controller.rb b/app/controllers/storage_locations_controller.rb index 9b22e22836..243fcc9fea 100644 --- a/app/controllers/storage_locations_controller.rb +++ b/app/controllers/storage_locations_controller.rb @@ -31,7 +31,7 @@ def index respond_to do |format| format.html format.csv do - send_data StorageLocation.generate_csv_from_inventory(@storage_locations, @inventory), filename: "StorageLocations-#{Time.zone.today}.csv" + send_data StorageLocation.generate_csv_from_inventory(@storage_locations, @inventory, current_organization), filename: "StorageLocations-#{Time.zone.today}.csv" end end end diff --git a/app/models/storage_location.rb b/app/models/storage_location.rb index a1f6d5bbd8..8571032117 100644 --- a/app/models/storage_location.rb +++ b/app/models/storage_location.rb @@ -163,18 +163,31 @@ def self.csv_export_headers # @param storage_locations [Array] # @param inventory [View::Inventory] + # @param current_organization [Organization] # @return [String] - def self.generate_csv_from_inventory(storage_locations, inventory) - all_items = inventory.all_items.uniq(&:item_id).sort_by(&:name) - additional_headers = all_items.map(&:name).uniq + def self.generate_csv_from_inventory(storage_locations, inventory, current_organization) + # Get all inventoried and organization items + all_inventoried_items = inventory.all_items + + # Not all items are inventoried, so we need to add the organization items to the headers. + # Yes it's another full table scan, but it's a small dataset and product wants the exports to consistently include all items (active, inactive, etc). + # This means we have to look for inactive items or items without inventory. + # note the remapping of item.id to item_id is to enable the uniq call to happen once across the two arrays. + all_organization_items = current_organization.items.select("DISTINCT ON (LOWER(name)) items.name, items.id as item_id").order("LOWER(name) ASC") + + all_items = (all_inventoried_items + all_organization_items).uniq(&:item_id).sort_by { |item| item&.name&.downcase } + + # Build headers from unique inventoried and organization items, using name as the key. + item_headers = all_items.map(&:name) + CSV.generate(headers: true) do |csv| csv_data = storage_locations.map do |sl| total_quantity = inventory.quantity_for(storage_location: sl.id) attributes = [sl.name, sl.address, sl.square_footage, sl.warehouse_type, total_quantity] + - all_items.map { |i| inventory.quantity_for(storage_location: sl.id, item_id: i.item_id) } + all_items.map { |item| inventory.quantity_for(storage_location: sl.id, item_id: item.item_id) } attributes.map { |attr| normalize_csv_attribute(attr) } end - ([csv_export_headers + additional_headers] + csv_data).each do |row| + ([csv_export_headers + item_headers] + csv_data).each do |row| csv << row end end diff --git a/spec/models/storage_location_spec.rb b/spec/models/storage_location_spec.rb index 7156930ef8..4e7e1719f3 100644 --- a/spec/models/storage_location_spec.rb +++ b/spec/models/storage_location_spec.rb @@ -241,6 +241,200 @@ expect(storage_location.longitude).not_to eq(nil) end end + + describe "to_csv" do + let(:organization) { create(:organization) } + let(:storage_location) { create(:storage_location, organization: organization) } + let(:item1) { create(:item, name: "Item 1", organization: organization) } + let(:item2) { create(:item, name: "Item 2", organization: organization) } + let(:item3) { create(:item, name: "Item 3", organization: organization) } + + before do + # Create items in the organization + [item1, item2, item3] + end + + it "generates a CSV with the correct headers and data" do + csv_data = storage_location.to_csv + parsed_csv = CSV.parse(csv_data, headers: true) + + # Check headers + expect(parsed_csv.headers).to eq(["Quantity", "DO NOT CHANGE ANYTHING IN THIS COLUMN"]) + + # Check data rows + expect(parsed_csv.count).to eq(3) # One row per item + expect(parsed_csv.map { |row| row["DO NOT CHANGE ANYTHING IN THIS COLUMN"] }).to match_array([item1.name, item2.name, item3.name]) + expect(parsed_csv.map { |row| row["Quantity"] }).to all(eq("")) + end + + it "includes all organization items in the CSV" do + csv_data = storage_location.to_csv + parsed_csv = CSV.parse(csv_data, headers: true) + + # Get all item names from the CSV + csv_item_names = parsed_csv.map { |row| row["DO NOT CHANGE ANYTHING IN THIS COLUMN"] } + + # Check that all organization items are included + expect(csv_item_names).to match_array(organization.items.pluck(:name)) + end + + it "generates a valid CSV string" do + csv_data = storage_location.to_csv + + # Verify it's a valid CSV string + expect { CSV.parse(csv_data) }.not_to raise_error + + # Verify it has the correct number of lines (header + items) + expect(csv_data.lines.count).to eq(organization.items.count + 1) + end + end + + describe "generate_csv_from_inventory" do + let(:organization) { create(:organization) } + let(:storage_location1) { create(:storage_location, name: "Location 1", organization: organization) } + let(:storage_location2) { create(:storage_location, name: "Location 2", organization: organization) } + let(:item1) { create(:item, name: "Item 1", organization: organization) } + let(:item2) { create(:item, name: "Item 2", organization: organization) } + let(:item3) { create(:item, name: "Item 3", organization: organization) } + let(:inventory) { View::Inventory.new(organization.id) } + + before do + # Create inventory for both storage locations + TestInventory.create_inventory(organization, { + storage_location1.id => { + item1.id => 10, + item2.id => 20 + }, + storage_location2.id => { + item2.id => 30, + item3.id => 40 + } + }) + end + + it "generates CSV with correct headers and data" do + csv_data = StorageLocation.generate_csv_from_inventory([storage_location1, storage_location2], inventory, organization) + parsed_csv = CSV.parse(csv_data, headers: true) + + # Check headers + expected_headers = ["Name", "Address", "Square Footage", "Warehouse Type", "Total Inventory", "Item 1", "Item 2", "Item 3"] + expect(parsed_csv.headers).to eq(expected_headers) + + # Check data rows + expect(parsed_csv.count).to eq(2) # One row per storage location + + # Check first storage location data + row1 = parsed_csv.find { |row| row["Name"] == storage_location1.name } + expect(row1["Total Inventory"]).to eq("30") # 10 + 20 + expect(row1["Item 1"]).to eq("10") + expect(row1["Item 2"]).to eq("20") + expect(row1["Item 3"]).to eq("0") + + # Check second storage location data + row2 = parsed_csv.find { |row| row["Name"] == storage_location2.name } + expect(row2["Total Inventory"]).to eq("70") # 30 + 40 + expect(row2["Item 1"]).to eq("0") + expect(row2["Item 2"]).to eq("30") + expect(row2["Item 3"]).to eq("40") + end + + context "when an organization's item exists but isn't in any storage location" do + let(:unused_item) { create(:item, name: "Unused Item", organization: organization) } + + it "includes the unused item as a column with 0 quantities" do + # Force unused_item to be created first + unused_item + + csv_data = StorageLocation.generate_csv_from_inventory([storage_location1, storage_location2], inventory, organization) + parsed_csv = CSV.parse(csv_data, headers: true) + + expect(parsed_csv.headers).to include("Unused Item") + + parsed_csv.each do |row| + expect(row["Unused Item"]).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) } + + it "includes the inactive item as a column with 0 quantities" do + # Force inactive_item to be created first + inactive_item + + csv_data = StorageLocation.generate_csv_from_inventory([storage_location1, storage_location2], inventory, organization) + parsed_csv = CSV.parse(csv_data, headers: true) + + expect(parsed_csv.headers).to include("Inactive Item") + + parsed_csv.each do |row| + expect(row["Inactive Item"]).to eq("0") + end + end + + context "when inactive item has the same name as an inventoried item" do + let(:inactive_item) { create(:item, name: "Item 1", organization: organization, active: false) } + + it "includes the inventory data from the active item" do + csv_data = StorageLocation.generate_csv_from_inventory([storage_location1, storage_location2], inventory, organization) + parsed_csv = CSV.parse(csv_data, headers: true) + + expect(parsed_csv.headers).to include("Item 1") + expect(parsed_csv.find { |row| row["Name"] == storage_location1.name }["Item 1"]).to eq("10") + end + end + end + + context "when generating CSV output" do + it "returns a valid CSV string" do + csv_data = StorageLocation.generate_csv_from_inventory([storage_location1, storage_location2], inventory, organization) + + expect(csv_data).to be_a(String) + expect { CSV.parse(csv_data) }.not_to raise_error + end + + it "includes headers as first row" do + csv_data = StorageLocation.generate_csv_from_inventory([storage_location1, storage_location2], inventory, organization) + csv_rows = CSV.parse(csv_data) + + expected_headers = ["Name", "Address", "Square Footage", "Warehouse Type", "Total Inventory", "Item 1", "Item 2", "Item 3"] + expect(csv_rows.first).to eq(expected_headers) + end + + it "includes data for all storage locations" do + csv_data = StorageLocation.generate_csv_from_inventory([storage_location1, storage_location2], inventory, organization) + csv_rows = CSV.parse(csv_data) + + expect(csv_rows.count).to eq(3) # Headers + 2 storage locations + end + end + + context "when items have different cases" do + let(:item_names) { ["Zebra", "apple", "Banana"] } + let(:expected_order) { ["apple", "Banana", item1.name, item2.name, item3.name, "Zebra"] } + let(:storage_location) { create(:storage_location, organization: organization) } + + before do + # Create items in random order to ensure sort is working + item_names.shuffle.each do |name| + create(:item, name: name, organization: organization) + end + end + + it "sorts item columns case-insensitively, ASC" do + csv_data = StorageLocation.generate_csv_from_inventory([storage_location], inventory, organization) + parsed_csv = CSV.parse(csv_data, headers: true) + + # Get just the item columns by removing the known base headers + base_headers = ["Name", "Address", "Square Footage", "Warehouse Type", "Total Inventory"] + item_columns = parsed_csv.headers - base_headers + + # Check that the remaining columns match our expected case-insensitive sort + expect(item_columns).to eq(expected_order) + end + end + end end describe "versioning" do