Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
53eaa68
Add Inventory Flow tab with quantity tracking and date filtering
Aug 7, 2025
bb56879
Refactor inventory flow to use ItemsFlowQuery for improved data handl…
Sep 21, 2025
6e923a6
Add date range filtering to ItemsFlowQuery and update Inventory Flow tab
Sep 22, 2025
2439b14
Remove unused inventory storage methods and related tests for cleaner…
Sep 22, 2025
5233723
Fix date filtering in ItemsFlowQuery to use item creation timestamps
Sep 26, 2025
0eda035
Fix date range filtering in storage location system spec to ensure ac…
Sep 26, 2025
216dc2c
Refactor item creation in flow query specs for improved clarity and a…
Sep 26, 2025
0d50f31
Refactor inventory flow query to utilize event-based data for improve…
Oct 2, 2025
bfd1db6
Enhance inventory flow query to include AuditEvent handling and updat…
Oct 27, 2025
233dd10
Merge branch 'main' into 5249-inventory-flow-tab
awwaiid Nov 23, 2025
2c1a417
Merge branch 'main' into 5249-inventory-flow-tab
awwaiid Nov 30, 2025
146129b
Merge remote-tracking branch 'origin/main' into 5249-inventory-flow-tab
awwaiid Jul 19, 2026
1898ec6
Fix flow accounting: event versions, kit events, and audit deltas
awwaiid Jul 19, 2026
d141971
Add start/end quantity columns and a separate adjustment/audit column
awwaiid Jul 20, 2026
c334702
Backdate edits in Inventory Flow and derive all columns from one replay
awwaiid Sep 27, 2026
f3c8552
Merge remote-tracking branch 'origin/main' into inventory-flow-backdated
awwaiid Sep 27, 2026
a740f90
Drop redundant keyword_init on Structs
awwaiid Sep 27, 2026
70f667b
Merge remote-tracking branch 'origin/main' into inventory-flow-backdated
awwaiid Oct 24, 2026
dcd9751
Inventory Flow: data classes, versioned? events, filter changes up front
awwaiid Oct 24, 2026
1af593e
Rename Event#versioned? to is_edit?
awwaiid Oct 24, 2026
5e04581
Inventory Flow: check only the previous version's items; fix tab aria…
awwaiid Oct 24, 2026
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
8 changes: 0 additions & 8 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
9 changes: 3 additions & 6 deletions app/controllers/storage_locations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,12 @@ 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
@flow = ItemsFlowQuery.new(organization: current_organization, storage_location: @storage_location,
date_range: helpers.selected_range).call
if View::Inventory.within_snapshot?(current_organization.id, version_date)
@inventory = View::Inventory.new(current_organization.id, event_time: version_date)
else
Expand Down
5 changes: 5 additions & 0 deletions app/events/audit_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,9 @@ def self.publish(audit)
)
)
end

# Sets quantities outright rather than editing an earlier version.
def is_edit?
false
end
end
13 changes: 6 additions & 7 deletions app/events/inventory_aggregate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,13 @@ def inventory_for(organization_id, event_time: nil, validate: false)
event_hash = {}
events.group_by(&:group_id).each do |_, event_batch|
last_grouped_event = event_batch.max_by(&:updated_at)
# don't do grouping for UpdateExistingEvents
if event_batch.any? { |e| e.is_a?(UpdateExistingEvent) }
handle(last_grouped_event, inventory, validate: validate)
yield last_grouped_event, inventory if block_given?
next
previous_event = nil
# only edits are diffed against the previous version of their record
if last_grouped_event.is_edit?
key = [last_grouped_event.eventable_type, last_grouped_event.eventable_id]
previous_event = event_hash[key]
event_hash[key] = last_grouped_event
end
previous_event = event_hash[[last_grouped_event.eventable_type, last_grouped_event.eventable_id]]
event_hash[[last_grouped_event.eventable_type, last_grouped_event.eventable_id]] = last_grouped_event
handle(last_grouped_event, inventory, validate: validate, previous_event: previous_event)
yield last_grouped_event, inventory if block_given?
end
Expand Down
5 changes: 5 additions & 0 deletions app/events/kit_allocate_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,9 @@ def self.publish(kit, storage_location, quantity)
)
)
end

# Each allocation is its own change rather than an edit of the kit.
def is_edit?
false
end
end
5 changes: 5 additions & 0 deletions app/events/kit_deallocate_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,9 @@ def self.publish(kit, storage_location, quantity)
)
)
end

# Each deallocation is its own change rather than an edit of the kit.
def is_edit?
false
end
end
6 changes: 6 additions & 0 deletions app/events/update_existing_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,10 @@ def publish(itemizable, previous_line_items, original_storage_location)
)
end
end

# Carries the difference from the previous version rather than a full
# version, so it is applied on its own instead of replacing an earlier event.
def is_edit?
false
end
end
8 changes: 8 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ def no_intervening_snapshot
end
end

# Whether events of this type edit their record: each one is a full version
# of the eventable (e.g. a donation, then each later edit of it) that replaces
# the previous event for that record, rather than a standalone change.
# @return [Boolean]
def is_edit?
true
end

# @return [Array<Option>]
def self.types_for_select
descendants.map do |klass|
Expand Down
179 changes: 179 additions & 0 deletions app/queries/items_flow_query.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# frozen_string_literal: true

# Summarizes how each item's quantity at a storage location changed over a
# date range: quantity at start, in, out, adjustments/audits, and at end.
#
# Numbers are reported "as of now": an edit or deletion is backdated to when
# the record was first created, so a donation of 100 that was later edited to
# 150 shows 150 on its original date, and a deleted donation shows nothing.
#
# To do this we replay the inventory once (the same replay used everywhere
# else, so audits, kits and edits are handled exactly as InventoryAggregate
# handles them) and total up the net change each record made to each item at
# this location, dated to when the record was first created. Records from
# before the range make up the start quantity and records within it make up
# the in, out and adjustment columns, so start + change always equals end.
class ItemsFlowQuery
Result = Data.define(:rows, :totals)

# How one item's quantity moved over the date range. The totals row has no
# item.
Row = Data.define(:item_id, :item_name, :quantity_start, :quantity_in, :quantity_out, :quantity_adjustment) do
# @param rows [Array<Row>]
# @return [Row]
def self.total(rows)
new(
item_id: nil,
item_name: nil,
quantity_start: rows.sum(&:quantity_start),
quantity_in: rows.sum(&:quantity_in),
quantity_out: rows.sum(&:quantity_out),
quantity_adjustment: rows.sum(&:quantity_adjustment)
)
end

def change
quantity_in - quantity_out + quantity_adjustment
end

def quantity_end
quantity_start + change
end

def empty?
[quantity_start, quantity_in, quantity_out, quantity_adjustment].all?(&:zero?)
end
end

ADJUSTMENT_TYPES = %w[AdjustmentEvent AuditEvent].freeze

# @param organization [Organization]
# @param storage_location [StorageLocation]
# @param date_range [Range<Time>, nil] nil means everything since the last snapshot
def initialize(organization:, storage_location:, date_range: nil)
@organization = organization
@storage_location = storage_location
@date_range = date_range
end

# @return [Result]
def call
snapshot_quantities, records = replay
changes_by_item = records.flat_map(&:item_changes).group_by(&:item_id)

item_ids = (snapshot_quantities.keys + changes_by_item.keys).uniq
names = Item.where(id: item_ids).pluck(:id, :name).to_h

rows = item_ids.filter_map do |item_id|
row = build_row(item_id, names[item_id], snapshot_quantities.fetch(item_id, 0), changes_by_item.fetch(item_id, []))
row unless row.empty?
end.sort_by { |row| row.item_name.to_s }

Result.new(rows: rows, totals: Row.total(rows))
end

private

# The net change a record made to one item at this location.
ItemChange = Data.define(:item_id, :time, :adjustment, :quantity)

# Everything one record (e.g. a donation and all of its edits) changed at
# this location, dated to when the record was first created.
class RecordChanges
def initialize(time:, adjustment:)
@time = time
@adjustment = adjustment
@previous_item_ids = []
@quantities = Hash.new(0)
end

# Records the items a new version of the record touches. An edit can
# remove an item, so the items the previous version touched may have
# changed too.
# @param item_ids [Array<Integer>]
# @return [Array<Integer>] the items this version or the previous one touched
def touch(item_ids)
touched = @previous_item_ids | item_ids
@previous_item_ids = item_ids
touched
end

# @param item_id [Integer]
# @param quantity [Integer]
def add(item_id, quantity)
@quantities[item_id] += quantity
end

# @return [Array<ItemChange>]
def item_changes
@quantities.filter_map do |item_id, quantity|
next if quantity.zero?

ItemChange.new(item_id: item_id, time: @time, adjustment: @adjustment, quantity: quantity)
end
end
end
private_constant :ItemChange, :RecordChanges

# Replays the inventory, totalling up the change each record made to each
# item at this location.
# @return [Array(Hash<Integer, Integer>, Array<RecordChanges>)] quantities at
# the snapshot the replay started from, and each record's changes since
def replay
snapshot_quantities = {}
quantities = Hash.new(0) # item_id => quantity after the last event
records = {}

InventoryAggregate.inventory_for(@organization.id) do |event, inventory|
items = inventory.storage_locations[@storage_location.id]&.items || {}
if event.is_a?(SnapshotEvent)
snapshot_quantities = items.transform_values(&:quantity)
quantities = Hash.new(0).merge(snapshot_quantities)
next
end

key = event.is_edit? ? [event.eventable_type, event.eventable_id] : [:event, event.id]
record = records[key] ||= RecordChanges.new(time: event.event_time, adjustment: ADJUSTMENT_TYPES.include?(event.type))
record.touch(item_ids_here(event)).each do |item_id|
quantity = items[item_id]&.quantity.to_i
record.add(item_id, quantity - quantities[item_id])
quantities[item_id] = quantity
end
end

[snapshot_quantities, records.values]
end

# @return [Array<Integer>] the items the event moves into or out of this location
def item_ids_here(event)
event.data.items.filter_map do |line_item|
line_item.item_id if [line_item.to_storage_location, line_item.from_storage_location].include?(@storage_location.id)
end
end

# @param snapshot_quantity [Integer]
# @param changes [Array<ItemChange>] the item's changes since the snapshot
# @return [Row]
def build_row(item_id, item_name, snapshot_quantity, changes)
before = changes.select { |change| before_range?(change.time) }
adjustments, moves = changes.select { |change| in_range?(change.time) }.partition(&:adjustment)
ins, outs = moves.partition { |change| change.quantity.positive? }

Row.new(
item_id: item_id,
item_name: item_name,
quantity_start: snapshot_quantity + before.sum(&:quantity),
quantity_in: ins.sum(&:quantity),
quantity_out: -outs.sum(&:quantity),
quantity_adjustment: adjustments.sum(&:quantity)
)
end

def before_range?(time)
!@date_range.nil? && time < @date_range.begin
end

def in_range?(time)
@date_range.nil? || @date_range.cover?(time)
end
end
29 changes: 0 additions & 29 deletions app/queries/items_in_query.rb

This file was deleted.

29 changes: 0 additions & 29 deletions app/queries/items_in_total_query.rb

This file was deleted.

29 changes: 0 additions & 29 deletions app/queries/items_out_query.rb

This file was deleted.

Loading
Loading