diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index fc9a7dccba..84863fa2fa 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -86,10 +86,6 @@ Layout/ExtraSpacing: - 'Guardfile' - 'app/models/barcode_item.rb' - 'app/queries/items_by_storage_collection_query.rb' - - 'app/queries/items_in_query.rb' - - 'app/queries/items_in_total_query.rb' - - 'app/queries/items_out_query.rb' - - 'app/queries/items_out_total_query.rb' - 'config/environments/development.rb' - 'config/environments/test.rb' - 'config/puma.rb' @@ -169,10 +165,6 @@ Layout/MultilineMethodCallIndentation: - 'app/models/partner_distribution.rb' - 'app/models/storage_location.rb' - 'app/queries/items_by_storage_collection_query.rb' - - 'app/queries/items_in_query.rb' - - 'app/queries/items_in_total_query.rb' - - 'app/queries/items_out_query.rb' - - 'app/queries/items_out_total_query.rb' - 'app/services/reports/acquisition_report_service.rb' - 'app/services/reports/adult_incontinence_report_service.rb' - 'app/services/reports/children_served_report_service.rb' diff --git a/app/controllers/storage_locations_controller.rb b/app/controllers/storage_locations_controller.rb index 4693c92c61..1b3001a773 100644 --- a/app/controllers/storage_locations_controller.rb +++ b/app/controllers/storage_locations_controller.rb @@ -54,15 +54,18 @@ def edit @storage_location = current_organization.storage_locations.find(params[:id]) end - # TODO: Move these queries to Query Object def show + setup_date_range_picker @storage_location = current_organization.storage_locations.find(params[:id]) version_date = params[:version_date].presence&.to_date - # TODO: Find a way to do these with less hard SQL. These queries have to be manually updated because they're not in-sync with the Model - @items_out = ItemsOutQuery.new(organization: current_organization, storage_location: @storage_location).call - @items_out_total = ItemsOutTotalQuery.new(organization: current_organization, storage_location: @storage_location).call - @items_in = ItemsInQuery.new(organization: current_organization, storage_location: @storage_location).call - @items_in_total = ItemsInTotalQuery.new(organization: current_organization, storage_location: @storage_location).call + @items = ItemsFlowQuery.new(storage_location: @storage_location, organization: current_organization, filter_params: date_range).call.to_a + totals = @items.first || {} + @total_quantity_start = totals["total_quantity_start"].to_i + @total_quantity_in = totals["total_quantity_in"].to_i + @total_quantity_out = totals["total_quantity_out"].to_i + @total_quantity_adjustment = totals["total_quantity_adjustment"].to_i + @total_quantity_change = totals["total_change"].to_i + @total_quantity_end = totals["total_quantity_end"].to_i if View::Inventory.within_snapshot?(current_organization.id, version_date) @inventory = View::Inventory.new(current_organization.id, event_time: version_date) else @@ -156,9 +159,18 @@ def include_omitted_items(existing_item_ids = []) end helper_method \ - def filter_params + def filter_params return {} unless params.key?(:filters) - params.require(:filters).permit(:containing) + params.require(:filters).permit(:containing, :date_range, :date_range_label) + end + + def date_range + return if filter_params[:date_range].blank? + + date_range = filter_params[:date_range].split(" - ") + start_date = Date.parse(date_range[0]).beginning_of_day + end_date = Date.parse(date_range[1]).end_of_day + [start_date, end_date] end end diff --git a/app/queries/items_flow_query.rb b/app/queries/items_flow_query.rb new file mode 100644 index 0000000000..5be2ee6c26 --- /dev/null +++ b/app/queries/items_flow_query.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +class ItemsFlowQuery + attr_reader :organization + attr_reader :filter_params + attr_reader :storage_location + + # Event types where editing the record republishes the full event for the + # same eventable and inventory replay diffs against the previous version + # (see InventoryAggregate). For flow purposes only the latest version per + # eventable counts. Destroy events publish zeroed line items, so destroyed + # records net out to nothing. + VERSIONED_EVENT_TYPES = %w[ + DonationEvent PurchaseEvent DistributionEvent TransferEvent AdjustmentEvent + DonationDestroyEvent PurchaseDestroyEvent DistributionDestroyEvent + TransferDestroyEvent UpdateExistingEvent + ].freeze + + # Event types where every event stands alone and applies in full + # ("ignore previous events" in InventoryAggregate). + # + # AuditEvent deliberately appears in neither list: audit payloads store the + # absolute counted quantity rather than a movement, so their flow + # contribution can't be summed in SQL - see the replay in #call, which + # derives each audit's delta from the running quantities. Adding AuditEvent + # here would double-count it. + STANDALONE_EVENT_TYPES = %w[KitAllocateEvent KitDeallocateEvent].freeze + + def initialize(organization:, storage_location:, filter_params: nil) + @organization = organization + @storage_location = storage_location + @filter_params = filter_params + end + + def call + flows = sql_flows + start_quantities = quantities_at(start_cutoff) + end_quantities = apply_audit_deltas_and_end_quantities(flows) + + item_ids = (flows.keys + start_quantities.keys + end_quantities.keys).uniq + names = Item.where(id: item_ids).pluck(:id, :name).to_h + + rows = item_ids.filter_map do |item_id| + flow = flows[item_id] || {in: 0, out: 0, adjustment: 0} + start_qty = start_quantities[item_id] || 0 + end_qty = end_quantities[item_id] || 0 + next if flow[:in].zero? && flow[:out].zero? && flow[:adjustment].zero? && start_qty == end_qty + + { + "item_id" => item_id, + "item_name" => flow[:name] || names[item_id], + "quantity_start" => start_qty, + "quantity_in" => flow[:in], + "quantity_out" => flow[:out], + "quantity_adjustment" => flow[:adjustment], + "change" => flow[:in] - flow[:out] + flow[:adjustment], + "quantity_end" => end_qty + } + end + rows.sort_by! { |row| row["item_name"].to_s } + + totals = { + "total_quantity_start" => rows.sum { |row| row["quantity_start"] }, + "total_quantity_in" => rows.sum { |row| row["quantity_in"] }, + "total_quantity_out" => rows.sum { |row| row["quantity_out"] }, + "total_quantity_adjustment" => rows.sum { |row| row["quantity_adjustment"] }, + "total_change" => rows.sum { |row| row["change"] }, + "total_quantity_end" => rows.sum { |row| row["quantity_end"] } + } + rows.each { |row| row.merge!(totals) } + + rows + end + + private + + # Inventory state can only be derived back to the most recent usable + # snapshot, so the effective window starts no earlier than just after it. + def start_cutoff + @start_cutoff ||= begin + requested = @filter_params ? @filter_params[0] : 20.years.ago + snapshot = Event.most_recent_snapshot(@organization.id) + if snapshot && requested <= snapshot.event_time + snapshot.event_time + 1.second + else + requested + end + end + end + + def end_cutoff + @end_cutoff ||= begin + requested = @filter_params ? @filter_params[1] : Time.current.end_of_day + [requested, start_cutoff].max + end + end + + # @return [Hash] item_id => quantity at this location + def quantities_at(event_time) + inventory = InventoryAggregate.inventory_for(@organization.id, event_time: event_time) + location_quantities(inventory) + end + + def location_quantities(inventory) + items = inventory.storage_locations[@storage_location.id]&.items || {} + items.transform_values(&:quantity).reject { |_, quantity| quantity.zero? } + end + + # Audits record the absolute counted quantity, not a delta, so their flow + # contribution is (counted quantity - quantity just before the audit). + # Replay the event log once up to the window end, tracking the audited + # items' running quantities at this location to turn each in-range audit + # into a delta; the same replay's final state is the window-end inventory. + # @return [Hash] item_id => quantity at the window end + def apply_audit_deltas_and_end_quantities(flows) + audits = AuditEvent.where(organization_id: @organization.id, event_time: start_cutoff..end_cutoff) + audited_item_ids = audits.flat_map { |audit| audit.data.items.map(&:item_id) }.uniq + audit_ids = audits.map(&:id).to_set + quantities_before = Hash.new(0) + + # The aggregate only applies an event_time cutoff that is after the last + # snapshot; when the window ends now-ish, replay without a cutoff. + cutoff = (end_cutoff >= Time.current) ? nil : end_cutoff + final_inventory = InventoryAggregate.inventory_for(@organization.id, event_time: cutoff) do |event, inventory| + next if audited_item_ids.empty? + + if audit_ids.include?(event.id) + event.data.items.each do |line_item| + next unless line_item.to_storage_location == @storage_location.id + + delta = line_item.quantity - quantities_before[line_item.item_id] + flow = flows[line_item.item_id] ||= {name: nil, in: 0, out: 0, adjustment: 0} + flow[:adjustment] += delta + end + end + + location = inventory.storage_locations[@storage_location.id] + audited_item_ids.each do |item_id| + quantities_before[item_id] = location&.items&.[](item_id)&.quantity.to_i + end + end + + location_quantities(final_inventory) + end + + # @return [Hash] item_id => {name:, in:, out:, adjustment:} + def sql_flows + query = <<~SQL + with latest_versioned_events as ( + select distinct on (eventable_type, eventable_id) * + from events + where organization_id = :organization_id + and type in (:versioned_types) + order by eventable_type, eventable_id, updated_at desc + ), + flow_events as ( + select id, type, data from latest_versioned_events + where event_time > :start_date and event_time <= :end_date + union all + select id, type, data from events + where organization_id = :organization_id + and type in (:standalone_types) + and event_time > :start_date and event_time <= :end_date + ), + item_flows as ( + select it.id as item_id, + it.name as item_name, + case when e.type != 'AdjustmentEvent' and (item->>'to_storage_location')::int = :id + then (item->>'quantity')::int + else 0 + end as quantity_in, + -- out quantities normalized to positive numbers + case when e.type != 'AdjustmentEvent' and (item->>'from_storage_location')::int = :id + then abs((item->>'quantity')::int) + else 0 + end as quantity_out, + -- adjustments carry signed quantities; net them into their own column + case when e.type = 'AdjustmentEvent' + then case when (item->>'to_storage_location')::int = :id then (item->>'quantity')::int else 0 end + - case when (item->>'from_storage_location')::int = :id then (item->>'quantity')::int else 0 end + else 0 + end as quantity_adjustment + from flow_events e + join lateral jsonb_array_elements(e.data->'items') as item on true + left join items it on it.id = (item->>'item_id')::int and it.organization_id = :organization_id + where (item->>'to_storage_location')::int = :id + or (item->>'from_storage_location')::int = :id + ) + select + item_id, + item_name, + sum(quantity_in) as quantity_in, + sum(quantity_out) as quantity_out, + sum(quantity_adjustment) as quantity_adjustment + from item_flows + group by item_id, item_name + SQL + + result = ActiveRecord::Base.connection.exec_query( + ActiveRecord::Base.send(:sanitize_sql_array, [query, { + id: @storage_location.id, + organization_id: @organization.id, + versioned_types: VERSIONED_EVENT_TYPES, + standalone_types: STANDALONE_EVENT_TYPES, + start_date: start_cutoff, + end_date: end_cutoff + }]) + ) + + result.each_with_object({}) do |row, flows| + flows[row["item_id"]] = { + name: row["item_name"], + in: row["quantity_in"].to_i, + out: row["quantity_out"].to_i, + adjustment: row["quantity_adjustment"].to_i + } + end + end +end diff --git a/app/queries/items_in_query.rb b/app/queries/items_in_query.rb deleted file mode 100644 index 5792d8b8da..0000000000 --- a/app/queries/items_in_query.rb +++ /dev/null @@ -1,29 +0,0 @@ -# Creates a query object for retrieving the items, grouped by storage location -# We're using query objects for some of these more complicated queries to get -# the raw SQL out of the models and encapsulate it. -class ItemsInQuery - attr_reader :organization - attr_reader :filter_params - attr_reader :storage_location - - def initialize(organization:, storage_location:, filter_params: nil) - @organization = organization - @storage_location = storage_location - @filter_params = filter_params - end - - # rubocop:disable Naming/MemoizedInstanceVariableName - def call - @items ||= LineItem.joins(" -LEFT OUTER JOIN donations ON donations.id = line_items.itemizable_id AND line_items.itemizable_type = 'Donation' -LEFT OUTER JOIN purchases ON purchases.id = line_items.itemizable_id AND line_items.itemizable_type = 'Purchase' -LEFT OUTER JOIN items ON items.id = line_items.item_id -LEFT OUTER JOIN adjustments ON adjustments.id = line_items.itemizable_id AND line_items.itemizable_type = 'Adjustment' -LEFT OUTER JOIN transfers ON transfers.id = line_items.itemizable_id AND line_items.itemizable_type = 'Transfer'") - .where("(donations.storage_location_id = :id or purchases.storage_location_id = :id or (adjustments.storage_location_id = :id and line_items.quantity > 0) or transfers.to_id = :id) and items.organization_id = :organization_id", id: @storage_location.id, organization_id: @organization.id) - .select("sum(line_items.quantity) as quantity, items.id AS item_id, items.name") - .group("items.name, items.id") - .order("items.name") - end - # rubocop:enable Naming/MemoizedInstanceVariableName -end diff --git a/app/queries/items_in_total_query.rb b/app/queries/items_in_total_query.rb deleted file mode 100644 index fa6dc3f23b..0000000000 --- a/app/queries/items_in_total_query.rb +++ /dev/null @@ -1,29 +0,0 @@ -# Creates a query object for retrieving the items, grouped by storage location -# We're using query objects for some of these more complicated queries to get -# the raw SQL out of the models and encapsulate it. -class ItemsInTotalQuery - attr_reader :organization - attr_reader :filter_params - attr_reader :storage_location - - def initialize(organization:, storage_location:, filter_params: nil) - @organization = organization - @storage_location = storage_location - @filter_params = filter_params - end - - # rubocop:disable Naming/MemoizedInstanceVariableName - def call - @items ||= LineItem.joins(" -LEFT OUTER JOIN donations ON donations.id = line_items.itemizable_id AND line_items.itemizable_type = 'Donation' -LEFT OUTER JOIN purchases ON purchases.id = line_items.itemizable_id AND line_items.itemizable_type = 'Purchase' -LEFT OUTER JOIN items ON items.id = line_items.item_id -LEFT OUTER JOIN adjustments ON adjustments.id = line_items.itemizable_id AND line_items.itemizable_type = 'Adjustment' -LEFT OUTER JOIN transfers ON transfers.id = line_items.itemizable_id AND line_items.itemizable_type = 'Transfer'") - .where("(donations.storage_location_id = :id or purchases.storage_location_id = :id or (adjustments.storage_location_id = :id and line_items.quantity > 0) or transfers.to_id = :id) and items.organization_id = :organization_id", id: @storage_location.id, organization_id: @organization.id) - .sum("line_items.quantity") - end - # rubocop:enable Naming/MemoizedInstanceVariableName -end - - diff --git a/app/queries/items_out_query.rb b/app/queries/items_out_query.rb deleted file mode 100644 index 016245c6b2..0000000000 --- a/app/queries/items_out_query.rb +++ /dev/null @@ -1,29 +0,0 @@ -# Creates a query object for retrieving the items, grouped by storage location -# We're using query objects for some of these more complicated queries to get -# the raw SQL out of the models and encapsulate it. -class ItemsOutQuery - attr_reader :organization - attr_reader :filter_params - attr_reader :storage_location - - def initialize(organization:, storage_location:, filter_params: nil) - @organization = organization - @storage_location = storage_location - @filter_params = filter_params - end - - # rubocop:disable Naming/MemoizedInstanceVariableName - def call - @items ||= LineItem.joins(" -LEFT OUTER JOIN distributions ON distributions.id = line_items.itemizable_id AND line_items.itemizable_type = 'Distribution' -LEFT OUTER JOIN items ON items.id = line_items.item_id -LEFT OUTER JOIN adjustments ON adjustments.id = line_items.itemizable_id AND line_items.itemizable_type = 'Adjustment' -LEFT OUTER JOIN transfers ON transfers.id = line_items.itemizable_id AND line_items.itemizable_type = 'Transfer'") - .where("(distributions.storage_location_id = :id or (adjustments.storage_location_id= :id and line_items.quantity < 0) or transfers.from_id = :id) and items.organization_id= :organization_id", - id: @storage_location.id, organization_id: @organization.id) - .select("sum( case when line_items.quantity < 0 then -1*line_items.quantity else line_items.quantity END ) as quantity, items.id AS item_id, items.name") - .group("items.name, items.id") - .order("items.name") - end - # rubocop:enable Naming/MemoizedInstanceVariableName -end diff --git a/app/queries/items_out_total_query.rb b/app/queries/items_out_total_query.rb deleted file mode 100644 index ad0d39e124..0000000000 --- a/app/queries/items_out_total_query.rb +++ /dev/null @@ -1,26 +0,0 @@ -# Creates a query object for retrieving the items, grouped by storage location -# We're using query objects for some of these more complicated queries to get -# the raw SQL out of the models and encapsulate it. -class ItemsOutTotalQuery - attr_reader :organization - attr_reader :filter_params - attr_reader :storage_location - - def initialize(organization:, storage_location:, filter_params: nil) - @organization = organization - @storage_location = storage_location - @filter_params = filter_params - end - - # rubocop:disable Naming/MemoizedInstanceVariableName - def call - @items ||= LineItem.joins(" -LEFT OUTER JOIN distributions ON distributions.id = line_items.itemizable_id AND line_items.itemizable_type = 'Distribution' -LEFT OUTER JOIN items ON items.id = line_items.item_id -LEFT OUTER JOIN adjustments ON adjustments.id = line_items.itemizable_id AND line_items.itemizable_type = 'Adjustment' -LEFT OUTER JOIN transfers ON transfers.id = line_items.itemizable_id AND line_items.itemizable_type = 'Transfer'") - .where("(distributions.storage_location_id = :id or (adjustments.storage_location_id= :id and line_items.quantity < 0) or transfers.from_id = :id) and items.organization_id= :organization_id", id: @storage_location.id, organization_id: @organization.id) - .sum("case when line_items.quantity < 0 then -1*line_items.quantity else line_items.quantity END") - end - # rubocop:enable Naming/MemoizedInstanceVariableName -end diff --git a/app/views/storage_locations/_item_row.html.erb b/app/views/storage_locations/_item_row.html.erb new file mode 100644 index 0000000000..3ed11818b2 --- /dev/null +++ b/app/views/storage_locations/_item_row.html.erb @@ -0,0 +1,10 @@ +<% css_class = item_row["change"].negative? ? 'modal-body-warning-text' : '' %> +"> + <%= link_to item_row["item_name"], item_path(item_row["item_id"]) %> + <%= item_row["quantity_start"] %> + <%= item_row["quantity_in"] %> + <%= item_row["quantity_out"] %> + <%= item_row["quantity_adjustment"] %> + <%= item_row["change"] %> + <%= item_row["quantity_end"] %> + diff --git a/app/views/storage_locations/show.html.erb b/app/views/storage_locations/show.html.erb index 066dc3a7ec..7deb8f4a34 100644 --- a/app/views/storage_locations/show.html.erb +++ b/app/views/storage_locations/show.html.erb @@ -68,21 +68,18 @@
-
+
<%= form_for @storage_location, method: :get do %> Show Inventory at Date: <%= date_field_tag 'version_date', params[:version_date], min: InventoryItem::EARLIEST_VERSION, autocomplete: "on" %> <%= filter_button(text: 'View') %> @@ -131,41 +128,45 @@
-
- - - - - - - - - <%= render partial: "line_item_row", collection: @items_in %> - - - - - - - -
ItemQuantity
Total<%= @items_in_total %>
-
- -
+
+ <%= form_for @storage_location, method: :get do %> + <%= label_tag "Date Range" %> +
+
+ <%= render partial: "shared/date_range_picker", locals: {css_class: "form-control"} %> +
+
+ <%= filter_button %> + <%= clear_filter_button %> +
+
+ <% end %> +

- + + + + + + - <%= render partial: "line_item_row", collection: @items_out %> + <%= render partial: "item_row", collection: @items %> - + + + + + <% css_class = @total_quantity_change.negative? ? 'modal-body-warning-text' : '' %> + +
ItemQuantityQuantity at StartQuantity InQuantity OutQuantity Adjustment/AuditQuantity ChangeQuantity at End
Total<%= @items_out_total %><%= @total_quantity_start %><%= @total_quantity_in %><%= @total_quantity_out %><%= @total_quantity_adjustment %><%= @total_quantity_change %><%= @total_quantity_end %>
diff --git a/spec/queries/items_flow_query_spec.rb b/spec/queries/items_flow_query_spec.rb new file mode 100644 index 0000000000..dbd75bc226 --- /dev/null +++ b/spec/queries/items_flow_query_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "rspec" + +RSpec.describe ItemsFlowQuery do + let(:items) { create_list(:item, 2) } + let!(:storage_location) { create(:storage_location, name: "here") } + let(:organization) { storage_location.organization } + let!(:result) do + [ + { + item_id: items[0].id, + item_name: items[0].name, + quantity_start: 0, + quantity_in: 10, + quantity_out: 5, + quantity_adjustment: 0, + change: 5, + quantity_end: 5, + total_quantity_start: 0, + total_quantity_in: 13, + total_quantity_out: 7, + total_quantity_adjustment: 2, + total_change: 8, + total_quantity_end: 8 + }, + { + # Donation (+3) flows in and the transfer (-2) flows out. The + # adjustment (+3) lands in the adjustment column, as does the audit: + # it counts an absolute quantity of 3 when the running quantity is 4, + # so it contributes its delta of -1, netting +2. + item_id: items[1].id, + item_name: items[1].name, + quantity_start: 0, + quantity_in: 3, + quantity_out: 2, + quantity_adjustment: 2, + change: 3, + quantity_end: 3, + total_quantity_start: 0, + total_quantity_in: 13, + total_quantity_out: 7, + total_quantity_adjustment: 2, + total_change: 8, + total_quantity_end: 8 + } + ].map(&:with_indifferent_access) + end + + before do + create(:donation, :with_items, item: items[0], item_quantity: 10, storage_location: storage_location) + distribution = create(:distribution, :with_items, item: items[0], item_quantity: 5, storage_location: storage_location) + DistributionEvent.publish(distribution) + create(:donation, :with_items, item: items[1], item_quantity: 3, storage_location: storage_location) + adjustment = create(:adjustment, :with_items, item: items[1], item_quantity: 3, storage_location: storage_location) + AdjustmentEvent.publish(adjustment) + transfer = create(:transfer, :with_items, item: items[1], item_quantity: 2, from: storage_location, to: create(:storage_location)) + TransferEvent.publish(transfer) + audit = create(:audit, :with_items, item: items[1], item_quantity: 3, adjustment: adjustment, storage_location: storage_location) + AuditEvent.publish(audit) + end + + subject { described_class.new(organization: organization, storage_location: storage_location).call } + + context "without filter params" do + it "returns array of hashes" do + expect(subject.to_a).to match_array(result) + end + end + + context "with filter params" do + let(:filter_params) { [11.days.ago, 9.days.ago] } + let!(:old_items) { create_list(:item, 2) } + let(:other_location) { create(:storage_location, organization: organization) } + + subject { described_class.new(organization: organization, storage_location: storage_location, filter_params: filter_params).call } + + before do + create(:donation, :with_items, item: old_items[0], item_quantity: 10, storage_location: storage_location) + Event.last.update(event_time: 10.days.ago) + create(:donation, :with_items, item: old_items[1], item_quantity: 8, storage_location: storage_location) + Event.last.update(event_time: 12.days.ago) + distribution = create(:distribution, :with_items, item: old_items[1], item_quantity: 5, storage_location: storage_location) + DistributionEvent.publish(distribution) + Event.last.update(event_time: 10.days.ago) + end + + let(:filtered_result) do + [ + { + item_id: old_items[0].id, + item_name: old_items[0].name, + quantity_start: 0, + quantity_in: 10, + quantity_out: 0, + quantity_adjustment: 0, + change: 10, + quantity_end: 10, + total_quantity_start: 8, + total_quantity_in: 10, + total_quantity_out: 5, + total_quantity_adjustment: 0, + total_change: 5, + total_quantity_end: 13 + }, + { + # Donated (+8) before the window, so it only shows in the starting + # quantity; the in-window distribution (-5) flows out. + item_id: old_items[1].id, + item_name: old_items[1].name, + quantity_start: 8, + quantity_in: 0, + quantity_out: 5, + quantity_adjustment: 0, + change: -5, + quantity_end: 3, + total_quantity_start: 8, + total_quantity_in: 10, + total_quantity_out: 5, + total_quantity_adjustment: 0, + total_change: 5, + total_quantity_end: 13 + } + ].map(&:with_indifferent_access) + end + + it "returns array of hashes" do + expect(subject.to_a).to match_array(filtered_result) + end + end + + context "when a donation has been edited" do + let!(:fresh_organization) { create(:organization) } + let(:location) { create(:storage_location, organization: fresh_organization) } + let(:item) { create(:item, organization: fresh_organization) } + + subject { described_class.new(organization: fresh_organization, storage_location: location).call } + + it "only counts the latest version of the donation" do + donation = create(:donation, :with_items, item: item, item_quantity: 10, storage_location: location, organization: fresh_organization) + donation.line_items.first.update!(quantity: 25) + DonationEvent.publish(donation) + + row = subject.to_a.find { |r| r["item_id"] == item.id } + expect(row["quantity_in"]).to eq(25) + expect(row["quantity_end"]).to eq(25) + end + end + + context "when a donation has been destroyed" do + let!(:fresh_organization) { create(:organization) } + let(:location) { create(:storage_location, organization: fresh_organization) } + let(:item) { create(:item, organization: fresh_organization) } + + subject { described_class.new(organization: fresh_organization, storage_location: location).call } + + it "does not count the destroyed donation or list its item" do + donation = create(:donation, :with_items, item: item, item_quantity: 10, storage_location: location, organization: fresh_organization) + DonationDestroyEvent.publish(donation) + + expect(subject.to_a).to be_empty + end + end + + context "with kit allocations" do + let!(:fresh_organization) { create(:organization) } + let(:location) { create(:storage_location, organization: fresh_organization) } + let(:content_item) { create(:item, organization: fresh_organization) } + let(:kit) do + kit_params = { + organization_id: fresh_organization.id, + name: "Flow Test Kit", + line_items_attributes: [{item_id: content_item.id, quantity: 2}] + } + KitCreateService.new(organization_id: fresh_organization.id, kit_params: kit_params).tap(&:call).kit + end + + subject { described_class.new(organization: fresh_organization, storage_location: location).call } + + it "counts kit contents flowing out and assembled kits flowing in" do + create(:donation, :with_items, item: content_item, item_quantity: 10, storage_location: location, organization: fresh_organization) + KitAllocateEvent.publish(kit, location.id, 3) + + rows = subject.to_a.index_by { |r| r["item_id"] } + expect(rows[kit.id]["quantity_in"]).to eq(3) + expect(rows[kit.id]["quantity_end"]).to eq(3) + expect(rows[content_item.id]["quantity_in"]).to eq(10) + expect(rows[content_item.id]["quantity_out"]).to eq(6) + expect(rows[content_item.id]["quantity_end"]).to eq(4) + end + end +end diff --git a/spec/queries/items_in_query_spec.rb b/spec/queries/items_in_query_spec.rb deleted file mode 100644 index 25632c92e7..0000000000 --- a/spec/queries/items_in_query_spec.rb +++ /dev/null @@ -1,43 +0,0 @@ -# Spec for /app/queries/items_in_query.rb - -RSpec.describe ItemsInQuery do - let(:organization) { create(:organization) } - let!(:storage_location) { create(:storage_location, organization: organization) } - let(:other_item) { create(:item, organization: organization) } - let!(:other_storage_location) { create(:storage_location, :with_items, item: other_item, item_quantity: 10, organization: organization) } - - subject { ItemsInQuery.new(storage_location: storage_location, organization: organization).call } - - describe "items_in" do - before do - create_list(:donation, 2, :with_items, item: create(:item, organization: organization), item_quantity: 5, storage_location: storage_location, organization: organization) - create_list(:purchase, 2, :with_items, item: create(:item, organization: organization), item_quantity: 5, storage_location: storage_location, organization: organization) - create_list(:adjustment, 2, :with_items, item: create(:item, organization: organization), item_quantity: 5, storage_location: storage_location, organization: organization) - create_list(:transfer, 2, :with_items, item_quantity: 5, item: other_item, from: other_storage_location, to: storage_location, organization: organization) - end - - it "returns a collection with the fields name, item_id, quantity" do - expect(subject.first).to be_respond_to(:name) - expect(subject.first).to be_respond_to(:quantity) - expect(subject.first).to be_respond_to(:item_id) - end - - it "includes donations, purchases, adjustments, transfers among sources" do - expect(subject.to_a.size).to eq(4) - end - - it "does not count negative adjustments towards in-flow" do - shared_item = create(:item, organization: organization) - create(:donation, :with_items, item: shared_item, item_quantity: 10, storage_location: storage_location, organization: organization) - create(:adjustment, :with_items, item: shared_item, item_quantity: -10, storage_location: storage_location, organization: organization) - expect(subject.to_a.size).to eq(5) - end - - it "does not count transfers going in the negative direction" do - shared_item = create(:item, organization: organization) - create(:donation, :with_items, item: shared_item, item_quantity: 10, storage_location: storage_location, organization: organization) - create(:transfer, :with_items, item_quantity: 10, item: shared_item, from: storage_location, to: other_storage_location, organization: organization) - expect(subject.to_a.size).to eq(5) - end - end -end diff --git a/spec/queries/items_in_total_query_spec.rb b/spec/queries/items_in_total_query_spec.rb deleted file mode 100644 index 00c18b9bf7..0000000000 --- a/spec/queries/items_in_total_query_spec.rb +++ /dev/null @@ -1,53 +0,0 @@ -# Spec for /app/queries/items_in_total_query.rb - -RSpec.describe ItemsInTotalQuery do - let(:organization) { create(:organization) } - let!(:storage_location) { create(:storage_location, organization: organization) } - let(:other_item) { create(:item, organization: organization) } - let!(:other_storage_location) { create(:storage_location, :with_items, item: other_item, item_quantity: 10, organization: organization) } - let!(:shared_item) { create(:item, organization: organization) } - - subject { ItemsInTotalQuery.new(storage_location: storage_location, organization: organization).call } - - describe "items_in_total_query" do - before do - create(:donation, :with_items, item: create(:item, organization: organization), item_quantity: 10, storage_location: storage_location, organization: organization) - create(:purchase, :with_items, item: create(:item, organization: organization), item_quantity: 10, storage_location: storage_location, organization: organization) - create(:adjustment, :with_items, item: create(:item, organization: organization), item_quantity: 10, storage_location: storage_location, organization: organization) - create(:transfer, :with_items, item_quantity: 10, item: other_item, from: other_storage_location, to: storage_location, organization: organization) - end - - it "returns a sum total of all in-flows" do - expect(subject).to eq(40) - end - - it "counts shared items together" do - create(:donation, :with_items, item: shared_item, item_quantity: 50, storage_location: storage_location, organization: organization) - create(:purchase, :with_items, item: shared_item, item_quantity: 100, storage_location: storage_location, organization: organization) - create(:adjustment, :with_items, item: shared_item, item_quantity: 50, storage_location: storage_location, organization: organization) - create(:donation, :with_items, item: shared_item, item_quantity: 100, storage_location: storage_location, organization: organization) - create(:purchase, :with_items, item: shared_item, item_quantity: 50, storage_location: storage_location, organization: organization) - create(:adjustment, :with_items, item: shared_item, item_quantity: 100, storage_location: storage_location, organization: organization) - expect(subject).to eq(490) - end - - it "does not count negative adjustments towards in-flow" do - create(:donation, :with_items, item: shared_item, item_quantity: 10, storage_location: storage_location, organization: organization) - create(:adjustment, :with_items, item: shared_item, item_quantity: -10, storage_location: storage_location, organization: organization) - expect(subject).to eq(50) - end - - it "does not count transfers going in the negative direction" do - create(:donation, :with_items, item: shared_item, item_quantity: 10, storage_location: storage_location, organization: organization) - create(:transfer, :with_items, item_quantity: 10, item: shared_item, from: storage_location, to: other_storage_location, organization: organization) - expect(subject).to eq(50) - end - - it "does not count donations, purchases, adjustments to other storage locations" do - create(:donation, :with_items, item: create(:item, organization: organization), item_quantity: 100, storage_location: other_storage_location, organization: organization) - create(:purchase, :with_items, item: create(:item, organization: organization), item_quantity: 10, storage_location: other_storage_location, organization: organization) - create(:adjustment, :with_items, item: create(:item, organization: organization), item_quantity: 10, storage_location: other_storage_location, organization: organization) - expect(subject).to eq(40) - end - end -end diff --git a/spec/queries/items_out_query_spec.rb b/spec/queries/items_out_query_spec.rb deleted file mode 100644 index 9bbe735ac7..0000000000 --- a/spec/queries/items_out_query_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# Spec for /app/queries/items_out_query.rb - -RSpec.describe ItemsOutQuery do - let(:organization) { create(:organization) } - let!(:storage_location) { create(:storage_location, organization: organization) } - subject { ItemsOutQuery.new(storage_location: storage_location, organization: organization).call } - - describe "items_out_query" do - before do - items = create_list(:item, 3, organization: organization) - TestInventory.create_inventory(storage_location.organization, { - storage_location.id => items.to_h { |i| [i.id, 10] } - }) - other_storage_location = create(:storage_location, organization: organization) - create(:transfer, :with_items, item_quantity: 8, item: items[0], to: other_storage_location, from: storage_location, organization: organization) - create(:distribution, :with_items, item: items[1], item_quantity: 9, storage_location: storage_location, organization: organization) - create(:adjustment, :with_items, item: items[2], item_quantity: -10, storage_location: storage_location, organization: organization) - end - - it "returns a collection with the fields name, item_id, quantity" do - expect(subject.first).to be_respond_to(:name) - expect(subject.first).to be_respond_to(:quantity) - expect(subject.first).to be_respond_to(:item_id) - end - - it "includes distributions, adjustments, transfers among sources" do - expect(subject.to_a.size).to eq(3) - end - end -end diff --git a/spec/queries/items_out_total_query_spec.rb b/spec/queries/items_out_total_query_spec.rb deleted file mode 100644 index deab96d3ed..0000000000 --- a/spec/queries/items_out_total_query_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# Spec for /app/queries/items_out_total_query.rb - -RSpec.describe ItemsOutTotalQuery do - let(:organization) { create(:organization) } - let!(:storage_location) { create(:storage_location, organization: organization) } - subject { ItemsOutTotalQuery.new(storage_location: storage_location, organization: organization).call } - - describe "items_out_total_query" do - before do - items = create_list(:item, 3, organization: organization) - TestInventory.create_inventory(storage_location.organization, { - storage_location.id => items.to_h { |i| [i.id, 10] } - }) - other_storage_location = create(:storage_location, organization: organization) - create(:transfer, :with_items, item_quantity: 10, item: items[0], to: other_storage_location, from: storage_location, organization: organization) - create(:distribution, :with_items, item: items[1], item_quantity: 10, storage_location: storage_location, organization: organization) - create(:adjustment, :with_items, item: items[2], item_quantity: -10, storage_location: storage_location, organization: organization) - end - - it "returns a sum total of all out-flows" do - expect(subject).to eq(30) - end - end -end diff --git a/spec/system/storage_location_system_spec.rb b/spec/system/storage_location_system_spec.rb index aaff61b639..f2a08eff0a 100644 --- a/spec/system/storage_location_system_spec.rb +++ b/spec/system/storage_location_system_spec.rb @@ -99,13 +99,6 @@ click_on "View", match: :first - find("#custom-tabs-inventory-in-tab").click - - within "#custom-tabs-inventory-in" do - expect(page).to have_content("Needle") - expect(page).to have_content(100) - end - find("#custom-tabs-inventory-tab").click within "#custom-tabs-inventory" do @@ -191,24 +184,111 @@ end context "when viewing an existing storage location" do - let(:item) { create(:item, name: "AAA Diapers") } - let!(:storage_location) { create(:storage_location, :with_items, item: item, name: "here") } - let!(:adjustment) { create(:adjustment, :with_items, storage_location: storage_location) } + let(:items) { create_list(:item, 2) } + let!(:storage_location) { create(:storage_location, name: "here") } + let(:result) do + [ + { + item_id: items[0].id, + item_name: items[0].name, + quantity_start: 0, + quantity_in: 10, + quantity_out: 5, + quantity_adjustment: 0, + change: 5, + quantity_end: 5, + total_quantity_in: 13, + total_quantity_out: 7, + total_change: 9 + }, + { + # The adjustment (+3) shows in the adjustment column, not as in. + item_id: items[1].id, + item_name: items[1].name, + quantity_start: 0, + quantity_in: 3, + quantity_out: 2, + quantity_adjustment: 3, + change: 4, + quantity_end: 4, + total_quantity_in: 13, + total_quantity_out: 7, + total_change: 9 + } + ].map(&:with_indifferent_access) + end subject { storage_location_path(storage_location.id) } - it "Items in (adjustments)" do - visit subject - find("#custom-tabs-inventory-in-tab").click - - expect(page.find("#custom-tabs-inventory-in", visible: true)).to have_content "100" - end + context "Inventory Flow Tab" do + before do + create(:donation, :with_items, item: items[0], item_quantity: 10, storage_location: storage_location) + distribution = create(:distribution, :with_items, item: items[0], item_quantity: 5, storage_location: storage_location) + DistributionEvent.publish(distribution) + create(:donation, :with_items, item: items[1], item_quantity: 3, storage_location: storage_location) + adjustment = create(:adjustment, :with_items, item: items[1], item_quantity: 3, storage_location: storage_location) + AdjustmentEvent.publish(adjustment) + transfer = create(:transfer, :with_items, item: items[1], item_quantity: 2, from: storage_location, to: create(:storage_location)) + TransferEvent.publish(transfer) + visit subject + find("#custom-tabs-inventory-flow-tab").click + end - it "Items out (distributions)" do - create(:distribution, :with_items, storage_location: storage_location) - visit subject - find("#custom-tabs-inventory-out-tab").click + it "shows the inventory flow for the storage location" do + within("#custom-tabs-inventory-flow table tbody") do + result.each do |item| + row = find(:css, "tr[id='#{item[:item_id]}']") + change_column_css = item[:change].negative? ? "td.modal-body-warning-text" : "td" + expect(row).to have_link(item[:name], href: item_path(item[:item_id])) + expect(row).to have_css("td", text: item[:quantity_in]) + expect(row).to have_css("td", text: item[:quantity_out]) + expect(row).to have_css(change_column_css, text: item[:change]) + end + end + within("#custom-tabs-inventory-flow table tfoot") do + expect(page).to have_css("td", text: "Total") + expect(page).to have_css("td", text: result.first[:total_quantity_in]) + expect(page).to have_css("td", text: result.first[:total_quantity_out]) + expect(page).to have_css("td", text: result.first[:total_change]) + end + end - expect(page.find("#custom-tabs-inventory-out", visible: true)).to have_content "100" + context "date range filter" do + let!(:start_date) { 2.days.ago } + let!(:end_date) { 1.day.ago } + let!(:item) { create(:item, name: "Filtered Item") } + let(:result) do + [ + { + item_id: item.id, + item_name: item.name, + quantity_in: 10, + quantity_out: 0, + change: 10, + total_quantity_in: 10, + total_quantity_out: 0, + total_change: 10 + } + ].map(&:with_indifferent_access) + end + before do + create(:donation, :with_items, item: item, item_quantity: 10, storage_location: storage_location) + Event.last.update(event_time: start_date) + fill_in "filters[date_range]", with: "#{start_date} - #{end_date}" + click_button "Filter" + find("#custom-tabs-inventory-flow-tab").click + end + + it "filters the inventory flow by date range" do + within("#custom-tabs-inventory-flow table tbody") do + expect(page).to have_css("tr", count: 1) + row = find(:css, "tr[id='#{result.first[:item_id]}']") + expect(row).to have_link(result.first[:name], href: item_path(result.first[:item_id])) + expect(row).to have_css("td", text: result.first[:quantity_in]) + expect(row).to have_css("td", text: result.first[:quantity_out]) + expect(row).to have_css("td", text: result.first[:change]) + end + end + end end end end