Skip to content

Commit ff8c978

Browse files
authored
Merge pull request #5273 from rubyforgood/5262-editing-old-inventory
5262. Disallow editing of inventory past a snapshot
2 parents 27c05ea + 273dd8f commit ff8c978

24 files changed

+585
-197
lines changed

app/controllers/distributions_controller.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ def print
2727
end
2828

2929
def destroy
30-
result = DistributionDestroyService.new(params[:id]).call
30+
service = DistributionDestroyService.new(params[:id])
31+
result = service.call
3132

3233
if result.success?
3334
flash[:notice] = "Distribution #{params[:id]} has been reclaimed!"
3435
else
35-
flash[:error] = "Could not destroy distribution #{params[:id]}. Please contact technical support."
36+
flash[:error] = result.error.message
3637
end
3738

3839
redirect_to distributions_path
@@ -185,9 +186,10 @@ def edit
185186
@request = @distribution.request
186187
@items = current_organization.items.active.alphabetized
187188
@partner_list = current_organization.partners.alphabetized
189+
@changes_disallowed = SnapshotEvent.intervening(@distribution).present?
188190
@audit_warning = current_organization.audits
189191
.where(storage_location_id: @distribution.storage_location_id)
190-
.where("updated_at > ?", @distribution.created_at).any?
192+
.where("updated_at > ?", @distribution.created_at).any? && !@changes_disallowed
191193
inventory = View::Inventory.new(@distribution.organization_id)
192194
@storage_locations = current_organization.storage_locations.active.alphabetized.select do |storage_loc|
193195
!inventory.quantity_for(storage_location: storage_loc.id).negative?

app/controllers/donations_controller.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ def new
5555
def edit
5656
@donation = Donation.find(params[:id])
5757
@donation.line_items.build
58-
@audit_performed_and_finalized = Audit.finalized_since?(@donation, @donation.storage_location_id)
58+
@changes_disallowed = SnapshotEvent.intervening(@donation).present?
59+
@audit_performed_and_finalized = Audit.finalized_since?(@donation, @donation.storage_location_id) &&
60+
!@changes_disallowed
5961

6062
load_form_collections
6163
end
@@ -91,7 +93,7 @@ def destroy
9193
if service.success?
9294
flash[:notice] = "Donation #{params[:id]} has been removed!"
9395
else
94-
flash[:error] = "Donation #{params[:id]} failed to be removed because #{service.error}"
96+
flash[:error] = service.error.message
9597
end
9698

9799
redirect_to donations_path

app/controllers/purchases_controller.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ def new
6363
def edit
6464
@purchase = current_organization.purchases.find(params[:id])
6565
@purchase.line_items.build
66-
@audit_performed_and_finalized = Audit.finalized_since?(@purchase, @purchase.storage_location_id)
66+
@changes_disallowed = SnapshotEvent.intervening(@purchase).present?
67+
@audit_performed_and_finalized = Audit.finalized_since?(@purchase, @purchase.storage_location_id) &&
68+
!@changes_disallowed
6769

6870
load_form_collections(@purchase)
6971
end

app/events/snapshot_event.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
class SnapshotEvent < Event
22
serialize :data, coder: EventTypes::StructCoder.new(EventTypes::Inventory)
33

4+
# @param record [#organization_id, #created_at]
5+
# @return [SnapshotEvent, nil] the intervening snapshot event or null if there aren't any
6+
def self.intervening(record)
7+
where(organization_id: record.organization_id, event_time: record.created_at..)
8+
.order("created_at desc")
9+
.first
10+
end
11+
412
# @param organization [Organization]
513
def self.publish(organization)
614
inventory = InventoryAggregate.inventory_for(organization.id)

app/models/distribution.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class Distribution < ApplicationRecord
4141
validates :delivery_method, presence: true
4242
validate :line_items_quantity_is_positive
4343
validates :shipping_cost, numericality: { greater_than_or_equal_to: 0 }, allow_blank: true, if: :shipped?
44+
before_destroy :check_no_intervening_snapshot
4445

4546
before_save :combine_distribution, :reset_shipping_cost
4647

@@ -173,6 +174,13 @@ def line_items_quantity_is_positive
173174
line_items_quantity_is_at_least(1)
174175
end
175176

177+
def check_no_intervening_snapshot
178+
intervening = SnapshotEvent.intervening(self)
179+
if intervening
180+
raise "We can't delete distributions entered before #{intervening.event_time.to_date}."
181+
end
182+
end
183+
176184
def reset_shipping_cost
177185
self.shipping_cost = nil unless delivery_method == "shipped"
178186
end

app/models/donation.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class Donation < ApplicationRecord
6767
validates :source, presence: true, inclusion: { in: SOURCES.values, message: "Must be a valid source." }
6868
validates :money_raised, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
6969
validate :line_items_quantity_is_positive
70+
before_destroy :check_no_intervening_snapshot
7071

7172
# TODO: move this to Organization.donations as an extension
7273
scope :during, ->(range) { where(donations: { issued_at: range }) }
@@ -135,4 +136,11 @@ def combine_duplicates
135136
def line_items_quantity_is_positive
136137
line_items_quantity_is_at_least(1)
137138
end
139+
140+
def check_no_intervening_snapshot
141+
intervening = SnapshotEvent.intervening(self)
142+
if intervening
143+
raise "We can't delete donations entered before #{intervening.event_time.to_date}."
144+
end
145+
end
138146
end

app/models/event.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ class Event < ApplicationRecord
4040
self.user_id = PaperTrail.request&.whodunnit
4141
end
4242
after_create :validate_inventory
43+
validate :no_intervening_snapshot, on: :create
44+
45+
def no_intervening_snapshot
46+
return if is_a?(SnapshotEvent)
47+
return unless eventable.respond_to?(:organization)
48+
49+
intervening = SnapshotEvent.intervening(eventable)
50+
if intervening.present?
51+
errors.add(:base,
52+
"Cannot change inventory for an #{eventable.class.name.downcase} created before #{intervening.event_time.to_date}.")
53+
end
54+
end
4355

4456
# @return [Array<Option>]
4557
def self.types_for_select

app/models/purchase.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class Purchase < ApplicationRecord
5959

6060
validates :amount_spent_in_cents, numericality: { greater_than: 0 }
6161
validate :total_equal_to_all_categories
62+
before_destroy :check_no_intervening_snapshot
6263

6364
validates :amount_spent_on_diapers_cents, numericality: { greater_than_or_equal_to: 0 }
6465
validates :amount_spent_on_adult_incontinence_cents, numericality: { greater_than_or_equal_to: 0 }
@@ -132,4 +133,11 @@ def total_equal_to_all_categories
132133
"does not equal all categories - categories add to #{cat_total} but given total is #{total}")
133134
end
134135
end
136+
137+
def check_no_intervening_snapshot
138+
intervening = SnapshotEvent.intervening(self)
139+
if intervening
140+
raise "We can't delete purchases entered before #{intervening.event_time.to_date}."
141+
end
142+
end
135143
end

app/services/distribution_service.rb

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ def perform_distribution_service(&block)
1919
return self
2020
end
2121

22+
def distribution
23+
# Return distribution if it has already been defined
24+
return @distribution if instance_variable_defined? :@distribution
25+
26+
# Otherwise try to get this value with possibly
27+
# provided distribution_id from initialize
28+
@distribution = @distribution_id.present? ? Distribution.find(@distribution_id) : nil
29+
end
30+
2231
def success?
2332
error.nil?
2433
end
@@ -35,15 +44,6 @@ def set_error(error)
3544
@error = error
3645
end
3746

38-
def distribution
39-
# Return distribution if it has already been defined
40-
return @distribution if instance_variable_defined? :@distribution
41-
42-
# Otherwise try to get this value with possibly
43-
# provided distribution_id from initialize
44-
@distribution = @distribution_id.present? ? Distribution.find(@distribution_id) : nil
45-
end
46-
4747
def distribution_id
4848
return @distribution_id if instance_variable_defined? :@distribution_id
4949

Lines changed: 69 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,84 @@
11
module ItemizableUpdateService
2-
# @param itemizable [Itemizable]
3-
# @param params [Hash] Parameters passed from the controller. Should include `line_item_attributes`.
4-
# @param event_class [Class<Event>] the event class to publish the itemizable to.
5-
def self.call(itemizable:, params: {}, event_class: nil)
6-
original_storage_location = itemizable.storage_location
7-
StorageLocation.transaction do
8-
item_ids = params[:line_items_attributes]&.values&.map { |i| i[:item_id].to_i } || []
9-
inactive_item_names = Item.where(id: item_ids, active: false).pluck(:name)
10-
if inactive_item_names.any?
11-
raise "Update failed: The following items are currently inactive: #{inactive_item_names.join(", ")}. Please reactivate them before continuing."
2+
class << self
3+
# @param itemizable [Itemizable]
4+
# @param params [Hash] Parameters passed from the controller. Should include `line_item_attributes`.
5+
# @param event_class [Class<Event>] the event class to publish the itemizable to.
6+
def call(itemizable:, params: {}, event_class: nil)
7+
# we're updating just attributes on the itemizable, not line items
8+
if params[:line_items_attributes].blank? && SnapshotEvent.intervening(itemizable).present?
9+
itemizable.update!(params)
10+
itemizable.reload
11+
return
1212
end
1313

14-
from_location = to_location = itemizable.storage_location
15-
to_location = StorageLocation.find(params[:storage_location_id]) if params[:storage_location_id]
14+
original_storage_location = itemizable.storage_location
15+
StorageLocation.transaction do
16+
item_ids = params[:line_items_attributes]&.values&.map { |i| i[:item_id].to_i } || []
17+
inactive_item_names = Item.where(id: item_ids, active: false).pluck(:name)
18+
if inactive_item_names.any?
19+
raise "Update failed: The following items are currently inactive: #{inactive_item_names.join(", ")}. Please reactivate them before continuing."
20+
end
1621

17-
verify_intervening_audit_on_storage_location_items(itemizable: itemizable, from_location_id: from_location.id, to_location_id: to_location.id)
22+
from_location = to_location = itemizable.storage_location
23+
to_location = StorageLocation.find(params[:storage_location_id]) if params[:storage_location_id]
24+
25+
verify_intervening_audit_on_storage_location_items(itemizable: itemizable, from_location_id: from_location.id, to_location_id: to_location.id)
1826

19-
previous = nil
20-
# TODO once event sourcing has been out for long enough, we can safely remove this
21-
if Event.where(eventable: itemizable).none? || UpdateExistingEvent.where(eventable: itemizable).any?
2227
previous = itemizable.line_items.map(&:dup)
23-
end
28+
if inventory_changes?(previous, params[:line_items_attributes]) && SnapshotEvent.intervening(itemizable).present?
29+
raise "Cannot update #{itemizable.class.name.downcase} because there has been an intervening snapshot of the inventory."
30+
end
2431

25-
line_item_attrs = Array.wrap(params[:line_items_attributes]&.values)
26-
line_item_attrs.each { |attr| attr.delete(:id) }
32+
line_item_attrs = Array.wrap(params[:line_items_attributes]&.values)
33+
line_item_attrs.each { |attr| attr.delete(:id) }
2734

28-
update_storage_location(itemizable: itemizable, params: params)
29-
if previous
30-
UpdateExistingEvent.publish(itemizable, previous, original_storage_location)
31-
else
32-
event_class&.publish(itemizable)
35+
update_storage_location(itemizable: itemizable, params: params)
36+
37+
# TODO once event sourcing has been out for long enough, we can safely remove this
38+
if Event.where(eventable: itemizable).none? || UpdateExistingEvent.where(eventable: itemizable).any?
39+
UpdateExistingEvent.publish(itemizable, previous, original_storage_location)
40+
elsif inventory_changes?(previous, params[:line_items_attributes])
41+
event_class&.publish(itemizable)
42+
end
3343
end
3444
end
35-
end
3645

37-
# @param itemizable [Itemizable]
38-
# @param params [Hash] Parameters passed from the controller. Should include `line_item_attributes`.
39-
def self.update_storage_location(itemizable:, params:)
40-
# Delete the line items -- they'll be replaced later
41-
itemizable.line_items.delete_all
42-
# Update the current model with the new parameters
43-
itemizable.update!(params)
44-
itemizable.reload
45-
end
46+
# @param previous [Array<LineItem>] Previous line items before the update.
47+
# @param line_item_attributes [Hash] The new line item attributes from the params.
48+
# @return [Boolean] Returns true if the inventory has changed, false otherwise.
49+
def inventory_changes?(previous, line_item_attributes)
50+
previous_attrs = previous.to_h { |li| [li.item_id, li.quantity] }
51+
new_attrs = line_item_attributes.to_h do |_, li|
52+
[li[:item_id].to_i, li[:quantity].to_i]
53+
end
54+
previous_attrs != new_attrs
55+
end
56+
57+
# @param itemizable [Itemizable]
58+
# @param params [Hash] Parameters passed from the controller. Should include `line_item_attributes`.
59+
def update_storage_location(itemizable:, params:)
60+
# Delete the line items -- they'll be replaced later
61+
itemizable.line_items.delete_all
62+
# Update the current model with the new parameters
63+
itemizable.update!(params)
64+
itemizable.reload
65+
end
4666

47-
# @param itemizable [Itemizable]
48-
# @param from_location [StorageLocation]
49-
# @param to_location [StorageLocation]
50-
def self.verify_intervening_audit_on_storage_location_items(itemizable:, from_location_id:, to_location_id:)
51-
return if from_location_id == to_location_id || !Audit.finalized_since?(itemizable, [from_location_id, to_location_id])
52-
53-
itemizable_type = itemizable.class.name.downcase
54-
case itemizable_type
55-
when "distribution"
56-
raise "Cannot change the storage location because there has been an intervening audit of some items. " \
57-
"If you need to change the storage location, please reclaim this distribution and create a new distribution from the new storage location."
58-
else
59-
raise "Cannot change the storage location because there has been an intervening audit of some items. " \
60-
"If you need to change the storage location, please delete this #{itemizable_type} and create a new #{itemizable_type} with the new storage location."
67+
# @param itemizable [Itemizable]
68+
# @param from_location [StorageLocation]
69+
# @param to_location [StorageLocation]
70+
def verify_intervening_audit_on_storage_location_items(itemizable:, from_location_id:, to_location_id:)
71+
return if from_location_id == to_location_id || !Audit.finalized_since?(itemizable, [from_location_id, to_location_id])
72+
73+
itemizable_type = itemizable.class.name.downcase
74+
case itemizable_type
75+
when "distribution"
76+
raise "Cannot change the storage location because there has been an intervening audit of some items. " \
77+
"If you need to change the storage location, please reclaim this distribution and create a new distribution from the new storage location."
78+
else
79+
raise "Cannot change the storage location because there has been an intervening audit of some items. " \
80+
"If you need to change the storage location, please delete this #{itemizable_type} and create a new #{itemizable_type} with the new storage location."
81+
end
6182
end
6283
end
6384
end

0 commit comments

Comments
 (0)