Skip to content

Commit b1ad73a

Browse files
committed
5262. Disallow editing of inventory past a snapshot
1 parent 6b3c5c6 commit b1ad73a

5 files changed

Lines changed: 132 additions & 49 deletions

File tree

app/events/snapshot_event.rb

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

4+
# @param record [#organization_id, #created_at]
5+
# @return [Boolean] true if there is an intervening snapshot event
6+
def self.intervening?(record)
7+
where(organization_id: record.organization_id, event_time: record.created_at..).any?
8+
end
9+
410
# @param organization [Organization]
511
def self.publish(organization)
612
inventory = InventoryAggregate.inventory_for(organization.id)

app/models/event.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ class Event < ApplicationRecord
3939
self.user_id = PaperTrail.request&.whodunnit
4040
end
4141
after_create :validate_inventory
42+
validate :no_intervening_snapshot, on: :create
43+
44+
def no_intervening_snapshot
45+
return if is_a?(SnapshotEvent)
46+
return unless eventable.respond_to?(:organization)
47+
48+
if SnapshotEvent.intervening?(eventable)
49+
errors.add(:base,
50+
"Cannot change inventory for an old action that has an intervening snapshot.")
51+
end
52+
end
4253

4354
# @return [Array<Option>]
4455
def self.types_for_select
Lines changed: 63 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,77 @@
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."
12-
end
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+
original_storage_location = itemizable.storage_location
8+
StorageLocation.transaction do
9+
item_ids = params[:line_items_attributes]&.values&.map { |i| i[:item_id].to_i } || []
10+
inactive_item_names = Item.where(id: item_ids, active: false).pluck(:name)
11+
if inactive_item_names.any?
12+
raise "Update failed: The following items are currently inactive: #{inactive_item_names.join(", ")}. Please reactivate them before continuing."
13+
end
1314

14-
from_location = to_location = itemizable.storage_location
15-
to_location = StorageLocation.find(params[:storage_location_id]) if params[:storage_location_id]
15+
from_location = to_location = itemizable.storage_location
16+
to_location = StorageLocation.find(params[:storage_location_id]) if params[:storage_location_id]
1617

17-
verify_intervening_audit_on_storage_location_items(itemizable: itemizable, from_location_id: from_location.id, to_location_id: to_location.id)
18+
verify_intervening_audit_on_storage_location_items(itemizable: itemizable, from_location_id: from_location.id, to_location_id: to_location.id)
1819

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?
2220
previous = itemizable.line_items.map(&:dup)
23-
end
21+
if inventory_changes?(previous, params[:line_items_attributes]) && SnapshotEvent.intervening?(itemizable)
22+
raise "Cannot update #{itemizable.class.name.downcase} because there has been an intervening snapshot of the inventory."
23+
end
2424

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

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)
28+
update_storage_location(itemizable: itemizable, params: params)
29+
30+
# TODO once event sourcing has been out for long enough, we can safely remove this
31+
if Event.where(eventable: itemizable).none? || UpdateExistingEvent.where(eventable: itemizable).any?
32+
UpdateExistingEvent.publish(itemizable, previous, original_storage_location)
33+
elsif inventory_changes?(previous, params[:line_items_attributes])
34+
event_class&.publish(itemizable)
35+
end
3336
end
3437
end
35-
end
3638

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

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."
60+
# @param itemizable [Itemizable]
61+
# @param from_location [StorageLocation]
62+
# @param to_location [StorageLocation]
63+
def verify_intervening_audit_on_storage_location_items(itemizable:, from_location_id:, to_location_id:)
64+
return if from_location_id == to_location_id || !Audit.finalized_since?(itemizable, [from_location_id, to_location_id])
65+
66+
itemizable_type = itemizable.class.name.downcase
67+
case itemizable_type
68+
when "distribution"
69+
raise "Cannot change the storage location because there has been an intervening audit of some items. " \
70+
"If you need to change the storage location, please reclaim this distribution and create a new distribution from the new storage location."
71+
else
72+
raise "Cannot change the storage location because there has been an intervening audit of some items. " \
73+
"If you need to change the storage location, please delete this #{itemizable_type} and create a new #{itemizable_type} with the new storage location."
74+
end
6175
end
6276
end
6377
end

spec/models/event_spec.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,27 @@
7979
end
8080
end
8181
end
82+
83+
describe "#no_intervening_snapshot" do
84+
let(:eventable) {
85+
FactoryBot.create(:distribution,
86+
organization_id: organization.id,
87+
created_at: 1.week.ago)
88+
}
89+
let(:data) { EventTypes::Inventory.new(storage_locations: {}, organization_id: organization.id) }
90+
let(:payload) { EventTypes::InventoryPayload.new(items: []) }
91+
92+
it "prevents creation if an intervening snapshot exists" do
93+
travel(-1.day) do
94+
SnapshotEvent.create!(organization_id: organization.id,
95+
eventable: organization,
96+
data: data,
97+
event_time: Time.zone.now)
98+
end
99+
100+
expect {
101+
DistributionEvent.create!(eventable: eventable, organization_id: organization.id, data: payload)
102+
}.to raise_error(ActiveRecord::RecordInvalid, /Cannot change inventory for an old action that has an intervening snapshot/)
103+
end
104+
end
82105
end

spec/services/itemizable_update_service_spec.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,35 @@
262262
expect(UpdateExistingEvent.count).to eq(1)
263263
expect(View::Inventory.total_inventory(organization.id)).to eq(52) # 50 + 3 (item1) + 5 (item2) +- 6 (item3)
264264
end
265+
266+
it "should raise an error if there is an intervening snapshot" do
267+
itemizable.save!
268+
travel(-1.week)
269+
SnapshotEvent.publish(organization)
270+
travel 1.week
271+
itemizable.update!(created_at: 2.weeks.ago)
272+
expect do
273+
described_class.call(itemizable: itemizable, params: attributes, event_class: DistributionEvent)
274+
end.to raise_error("Cannot update distribution because there has been an intervening snapshot of the inventory.")
275+
end
276+
277+
it "should not raise an error if no inventory was changed" do
278+
no_change_attrs = {
279+
issued_at: 2.days.ago,
280+
line_items_attributes: {
281+
"0": {item_id: item1.id, quantity: 10},
282+
"1": {item_id: item2.id, quantity: 10}
283+
}
284+
}
285+
itemizable.save!
286+
travel(-1.week)
287+
SnapshotEvent.publish(organization)
288+
travel 1.week
289+
itemizable.update!(created_at: 2.weeks.ago)
290+
expect do
291+
described_class.call(itemizable: itemizable, params: no_change_attrs, event_class: DistributionEvent)
292+
end.to raise_error("Cannot update distribution because there has been an intervening snapshot of the inventory.")
293+
end
265294
end
266295
end
267296
end

0 commit comments

Comments
 (0)