Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/storage_locations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 18 additions & 5 deletions app/models/storage_location.rb
Original file line number Diff line number Diff line change
Expand Up @@ -163,18 +163,31 @@ def self.csv_export_headers

# @param storage_locations [Array<StorageLocation>]
# @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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could get moved to just being an options hash, which is my general preference vs array of args. Thoughts?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dorner -- do you have an opinion on this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use keyword arguments instead of an option hash.

# 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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a feature that should be built into the View::Inventory class. It's come up a few times. We should be able to specify "include non inventory items" in the all_items method.


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
Expand Down
194 changes: 194 additions & 0 deletions spec/models/storage_location_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading