seshbot-packing 0.8.6 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1693c794a4be401fe157bd5a6385e1375e37c738e8919a168cc881ec262f9ec
4
- data.tar.gz: 44c25e149bc8dba14cc2cc49f309b5e3ba5901bf4a636f89787ec5e946e28502
3
+ metadata.gz: 5a3ee88fef7cdb7044eb06951aaee1a388dc499a33226d553ecf1d4af3865df2
4
+ data.tar.gz: 7ceb6a0a5ad57b515e881352907e42c3bec29330c1055a8947e9662f03ad0ebc
5
5
  SHA512:
6
- metadata.gz: 59f4cb5bad8c401ef6dde61443937c3bbce68f37f659aef074175a067fd6ea91b346531bc1c809d7b2788a30969eb14c92d8f716fc382d56ac26560cec6c93fd
7
- data.tar.gz: c9536fe10e717e4841e8521f5986e467cc0e2b029c3a7512cee255f61941f0d0a5041c6ff0a9a047c218691d9b47ef69b14da6df7227f304cc4eb015f0ba1afa
6
+ metadata.gz: d7d04a459fbfc6dc06b7b028b535f5a1ab8e87579eb59f3aebc6803d82587abd7582c94074217e6935e189ba56d185138841aca429c3e774ff872c6e82e4a93c
7
+ data.tar.gz: 030363e4fe38d1f095766f836b86960639e8f3a23cbdde611c3d305cb259315816adc0a488cde7134f778958d3022f19056a0f850244324e0a844bc855195ba9
data/Gemfile CHANGED
@@ -4,3 +4,5 @@ source "https://rubygems.org"
4
4
  gemspec
5
5
  gem "rake", "~> 12.0"
6
6
  gem "minitest", "~> 5.0"
7
+
8
+ gem "timecop"
data/Gemfile.lock CHANGED
@@ -1,13 +1,14 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- seshbot-packing (0.8.6)
4
+ seshbot-packing (0.9.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
9
  minitest (5.14.2)
10
10
  rake (12.3.3)
11
+ timecop (0.9.4)
11
12
 
12
13
  PLATFORMS
13
14
  ruby
@@ -16,6 +17,7 @@ DEPENDENCIES
16
17
  minitest (~> 5.0)
17
18
  rake (~> 12.0)
18
19
  seshbot-packing!
20
+ timecop
19
21
 
20
22
  BUNDLED WITH
21
23
  2.1.4
data/README.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Seshbot::Packing
2
2
 
3
+ This gem allows inventory to be packed for shipping - used by BrewKeeper for pick/pack logic, and by Carrierbot to aid in calculating 'bundled' shipping rates
4
+
5
+ ## Recipes
6
+
7
+ The packing rules are described as 'recipes' - one of which is represented as a hash of the format:
8
+
9
+ ```ruby
10
+ {
11
+ 'input_fragment': 'B306',
12
+ 'input_quantity': 1,
13
+ 'output_fragment': 'B301',
14
+ 'output_quantity': '6',
15
+ 'is_reversible': true # optional
16
+ }
17
+ ```
18
+
19
+ The process of packing a collection of line-items in a customer shipment into a smplified set of line-items representing the most cost effective way to pack those items is as follows:
20
+
21
+ * get all quantity+SKUs in the order/fulfillments in the request
22
+ * unpack as much as possible by:
23
+ ** reverse all recipes inputs/outputs
24
+ ** find the ‘best’ recipe (the recipe that gives the MOST outputs)
25
+ ** repeat until no more unpacking can be done
26
+ * pack as much as possible by:
27
+ ** find the ‘best’ recipe (the recipe that gives the FEWEST outputs)
28
+ ** repeat until no more packing can be done
29
+
30
+ Therre area few special considerations:
31
+ - optional `is_reversible` flag: some recipes may not deemed reversible for the 'unpacking' phase; e.g., 3xB306->1xB324. These should be marked as 'is_reversible': false
32
+ - optional `phase` flag: packing recipes may be performed in 'phases' in order to ensure certain rules are adhered to for specific products or variant types (e.g., pack cans into special can-cartons before allowing the remainders to be mixed in with bottles)
33
+
34
+ Note that if a recipe is found, _it will be applied_ unless a better rule is found.
35
+
36
+ ## Building
37
+
38
+ * Update the VERSION file
39
+ * `gem build seshbot-packing.gemspec`
40
+ * commit changes
41
+ * `gem push seshbot-packing-VERSION.gem`
42
+
3
43
  ## Installation
4
44
 
5
45
  Add this line to your application's Gemfile:
@@ -35,15 +75,15 @@ Seshbot::Packing.count_product_types(items)
35
75
  ## returns the hashes where the sku value matches the given string
36
76
  items = [{ 'sku' => 'A-C306', 'quantity' => 4, 'price' => 1 },
37
77
  { 'sku' => 'A-B306', 'quantity' => 4, 'price' => 1 }]
38
- Seshbot::Packing.filter_by_sku(items, '-C')
78
+ Seshbot::Packing._filter_by_sku(items, '-C')
39
79
  #=> [{ 'sku' => 'A-C306', 'quantity' => 4, 'price' => 1 }]
40
80
  ## also takes a named argument
41
- Seshbot::Packing.filter_by_sku(items, '-C', inverse: true)
81
+ Seshbot::Packing._filter_by_sku(items, '-C', inverse: true)
42
82
  #=> [{ 'sku' => 'A-B306', 'quantity' => 4, 'price' => 1 }]
43
83
 
44
84
  # Substitute SKU
45
85
  ## replaces an sku in a hash
46
- Seshbot::Packing.substitute_sku(items, '-B', '-C')
86
+ Seshbot::Packing._substitute_sku(items, '-B', '-C')
47
87
  #=> items = [{"sku" => "A-C306", "quantity" => 2}, {"sku" => "B-C306", "quantity" => 1}]
48
88
  ```
49
89
 
@@ -4,16 +4,38 @@ module Seshbot
4
4
  module Packing
5
5
  # :nodocs:
6
6
  class LineItem
7
- attr_accessor :sku, :quantity, :price
7
+ attr_accessor :sku_fragment, :quantity, :price
8
8
 
9
- def initialize(sku, quantity, price=0)
10
- raise 'SKU must be a string' if sku.class != String
9
+ def initialize(sku_fragment, quantity, price=0)
10
+ raise 'SKU fragment must be a string' if sku_fragment.class != String
11
11
  raise 'Quantity must be an integer' if quantity.class != Integer
12
12
 
13
- @sku = sku
13
+ @sku_fragment = sku_fragment
14
14
  @quantity = quantity
15
15
  @price = price
16
16
  end
17
+
18
+ def to_s
19
+ "#{quantity}x#{sku_fragment}"
20
+ end
21
+
22
+ def self.summarise(items)
23
+ "#{items.map(&:to_s)}"
24
+ end
25
+
26
+ def self.total_quantity(items)
27
+ items.map { |i| i.quantity }.sum
28
+ end
29
+
30
+ def self.merge_line_items(items)
31
+ result = Hash.new(0)
32
+ # create a hash were the keys are sku_fragment, and the values are the summed quantities
33
+ items.each do |item|
34
+ result[item.sku_fragment] += item.quantity
35
+ end
36
+ result.delete_if { |k,v| v == 0 }
37
+ result.map { |sku_fragment, quantity| LineItem.new(sku_fragment, quantity) }
38
+ end
17
39
  end
18
- end
19
- end
40
+ end # module Packing
41
+ end # module Seshbot
@@ -14,141 +14,156 @@ module Seshbot
14
14
  @@logger
15
15
  end
16
16
 
17
- def filter_by_sku(items, sku_fragment, inverse: false)
18
- items.select do |i|
19
- matches = i.sku.include?(sku_fragment)
20
- inverse ? !matches : matches
17
+ def bundle_items(recipes, items, fulfilled_at: nil)
18
+ if !fulfilled_at.nil? && fulfilled_at < DateTime.parse('2020-01-14T08:00:00+09:00')
19
+ logger.debug "== Not Bundling (pre-2020 order) =="
20
+ return items
21
21
  end
22
- end
22
+ is_legacy = _is_legacy(fulfilled_at)
23
+ is_legacy_str = is_legacy ? " (LEGACY)" : ""
23
24
 
24
- def substitute_sku(items, from, to)
25
- items.map { |i| Seshbot::Packing::LineItem.new(i.sku.gsub(from, to), i.quantity) }
26
- end
25
+ logger.debug "== Bundling Items: #{items.map(&:to_s)}. Fulfilled at: #{fulfilled_at || '(no fulfillment date)'}#{is_legacy_str}"
27
26
 
28
- def count_product_types(items)
29
- result = Hash.new(0)
30
- # create a hash were the keys are sku_fragment, and the values are the summed quantities
31
- items.each do |item|
32
- size = packaged_size(item.sku)
33
- result[size] += item.quantity
27
+ results = []
28
+
29
+ if is_legacy
30
+ results = _bundle_items_legacy(recipes, items)
31
+ else
32
+ phases = recipes.values.map { |r| r['phase'] || 0 }.map(&:to_i).uniq.sort
33
+
34
+ results = items
35
+ phases.each do |phase|
36
+ phase_recipes = recipes.select { |name, recipe| (recipe['phase'] || 0) == phase }.to_h
37
+
38
+ logger.debug "=== Bundling phase #{phase}"
39
+
40
+ results = _bundle_items_single_phase(phase_recipes, results)
41
+ end
34
42
  end
35
- result.map { |k,v| Seshbot::Packing::LineItem.new("BUND-#{k}", v) }
43
+
44
+ logger.debug "Bundled result:"
45
+ logger.debug " - IN: #{items.map(&:to_s)}"
46
+ logger.debug " - OUT: #{results.map(&:to_s)}"
47
+
48
+ results
36
49
  end
37
50
 
38
- def packaged_size(sku)
39
- size_regex = /-(K?[LUP]\d{2}|[BCM]\d{3})/
40
- match = sku.match(size_regex)
41
- raise "Cannot determine packaged size for SKU: #{sku}" if match.nil? || match.captures.empty?
51
+ def unpack(recipes, items)
52
+ prev_result = items
42
53
 
43
- # this is everything inside the (..) in the regex (everything except the leading '-')
44
- result = match.captures[0]
45
- # HACK! for legacy reasons, we don't consider K to be part of the size.
46
- result.gsub!(/^K/, '')
54
+ (1..1000).each do |i|
55
+ new_result = _pack_single_step(recipes, prev_result, unpacking: true)
56
+ is_unchanged = new_result.map { |li| [li.sku_fragment, li.quantity] }.sort == prev_result.map { |li| [li.sku_fragment, li.quantity] }.sort
57
+ return new_result if is_unchanged
47
58
 
48
- if result == "B502"
49
- result = "B306"
59
+ prev_result = new_result
50
60
  end
51
- if result == "B504"
52
- result = "B312"
53
- end
54
- raise "Cannot determine packaged size for SKU: #{sku}" if result.nil?
55
-
56
- result
61
+ error_message = "could not unpack - infinite loop? (latest: #{prev_result.map(&:to_s)})"
62
+ logger.error error_message
63
+ raise error_message
57
64
  end
58
65
 
59
- def bundle_items(recipes, items, fulfilled_at: nil)
60
- logger.debug "***** BUNDLING"
61
- logger.debug "Bundling Items: #{items}. Fulfilled at: #{fulfilled_at || '(no fulfillment date)'}"
62
- if !fulfilled_at.nil? && fulfilled_at < DateTime.new(2020, 1, 14, 8, 0, 0, Time.new.zone)
63
- return items
66
+ def pack(recipes, items)
67
+ prev_result = items
68
+
69
+ # keep trying to 'pack' until it stabilises (e.g., 6x6pack => 1x24pack+2x6pack => 1x24pack+1x12pack)
70
+ (1..1000).each do |i|
71
+ new_result = _pack_single_step(recipes, prev_result, unpacking: false)
72
+ # no changes, break out
73
+ is_unchanged = new_result.map { |li| [li.sku_fragment, li.quantity] }.sort == prev_result.map { |li| [li.sku_fragment, li.quantity] }.sort
74
+ return new_result if is_unchanged
75
+
76
+ prev_result = new_result
64
77
  end
78
+ error_message = "could not pack - infinite loop? (latest: #{prev_result.map(&:to_s)})"
79
+ logger.error error_message
80
+ raise error_message
81
+ end
82
+
83
+ #
84
+ # private
85
+ #
86
+
87
+ def _is_legacy(fulfilled_at)
88
+ effective_fulfilled_at = fulfilled_at.nil? ? DateTime.now : fulfilled_at
89
+ is_legacy = effective_fulfilled_at < DateTime.parse('2021-04-07T09:00:00+09:00')
90
+ is_legacy
91
+ end
65
92
 
93
+ def _bundle_items_legacy(recipes, items)
66
94
  # get the cans first, and separate them out
67
- cans = filter_by_sku(items, '-C')
95
+ cans = _filter_by_sku_fragment_prefix(items, 'C')
96
+ remaining_cans = []
68
97
  unless cans.empty?
69
- bundle_items_by_type = count_product_types(cans)
98
+ bundle_items_by_type = LineItem.merge_line_items(cans)
70
99
  bundle_items_by_type = unpack(recipes, bundle_items_by_type)
71
100
  new_can_items = pack(recipes, bundle_items_by_type)
72
101
  # separate out all the C324s
73
- separated_c324s = filter_by_sku(new_can_items, '-C324')
74
- logger.debug "Cans: #{cans}"
75
- logger.debug " - Removed C324s: #{separated_c324s}"
102
+ separated_c324s = _filter_by_sku_fragment_prefix(new_can_items, 'C324')
103
+ logger.debug "Cans: #{cans.map(&:to_s)}"
104
+ logger.debug " - Removed C324s: #{separated_c324s.map(&:to_s)}"
76
105
 
77
- remaining_cans = filter_by_sku(new_can_items, '-C324', inverse: true)
78
- logger.debug " - Remaining skus: #{remaining_cans}"
106
+ remaining_cans = _filter_by_sku_fragment_prefix(new_can_items, 'C324', inverse: true)
107
+ logger.debug " - Remaining skus: #{remaining_cans.map(&:to_s)}"
79
108
  end
80
109
 
81
110
  # merge the remaining with the original leftover
82
- non_cans = filter_by_sku(items, '-C', inverse: true)
83
- remaining_cans ||= []
111
+ non_cans = _filter_by_sku_fragment_prefix(items, 'C', inverse: true)
84
112
  remaining_items = non_cans + remaining_cans
85
113
  logger.debug "Substituting:"
86
- logger.debug " - remaining items: #{remaining_items}"
114
+ logger.debug " - remaining items: #{remaining_items.map(&:to_s)}"
87
115
 
88
116
  # substitute C's for B's
89
- remaining_items = substitute_sku(remaining_items, '-C', '-B')
90
- logger.debug " - skus updated to: #{remaining_items}"
117
+ remaining_items = _substitute_sku(remaining_items, /^C/, 'B')
118
+ logger.debug " - skus updated to: #{remaining_items.map(&:to_s)}"
91
119
 
92
120
  # get a hash of {pack_6: 5, pack_12: 1}
93
- bundle_items_by_type = count_product_types(remaining_items)
121
+ bundle_items_by_type = LineItem.merge_line_items(remaining_items)
94
122
  # dismantle packages into individual units (e.g., {pack_6: 7})
95
123
  bundle_items_by_type = unpack(recipes, bundle_items_by_type)
96
124
  # repackage into the 'best' packaging we can figure out (e.g., {pack_12: 2})
97
125
  new_remaining_items = pack(recipes, bundle_items_by_type)
98
126
 
99
127
  separated_c324s ||= []
100
- merged_items = new_remaining_items + separated_c324s
101
- logger.debug "Bundled result:"
102
- logger.debug " - IN: #{items}"
103
- logger.debug " - OUT: #{merged_items}"
104
- merged_items
105
- end
128
+ results = new_remaining_items + separated_c324s
106
129
 
107
- def unpack(recipes, items)
108
-
109
- final_result = items
110
-
111
- loop do
112
- new_result = pack_single_step(recipes, final_result, unpacking: true)
113
- break if new_result.map { |li| [li.sku, li.quantity] }.sort == final_result.map { |li| [li.sku, li.quantity] }.sort
114
-
115
- final_result = new_result
116
- end
117
- final_result
130
+ results
118
131
  end
119
132
 
120
- def pack_single_step(recipes, items, unpacking:)
121
- result = Hash.new(0)
122
- items.each do |item|
123
- sku_size = packaged_size(item.sku)
124
- recipe = Recipe::find_best_recipe(recipes, sku_size, item.quantity, unpacking: unpacking)
125
- if recipe.nil?
126
- result[sku_size] += item.quantity
127
- next
128
- end
133
+ def _bundle_items_single_phase(recipes, items)
134
+ # get a list of items merged together by variant type (e.g., 10xBIGI-C301 + 5xBISS-C301 => 15xBUND-C301))
135
+ merged_items = LineItem.merge_line_items(items)
136
+ # dismantle packages into individual units
137
+ unpacked_items = unpack(recipes, merged_items)
138
+ # repackage into the 'best' packaging we can figure out
139
+ pack(recipes, unpacked_items)
140
+ end
129
141
 
130
- recipe_quantities = Recipe::apply_recipe(recipe, item.quantity)
131
- result[recipe["input_fragment"]] += recipe_quantities[:input_quantity]
132
- result[recipe["output_fragment"]] += recipe_quantities[:output_quantity]
142
+ def _filter_by_sku_fragment_prefix(items, sku_fragment, inverse: false)
143
+ items.select do |i|
144
+ matches = i.sku_fragment.start_with? sku_fragment
145
+ inverse ? !matches : matches
133
146
  end
134
- result.delete_if { |k,v| v == 0}
135
- result.map { |k,v| Seshbot::Packing::LineItem.new("BUND-#{k}", v) }
136
147
  end
137
148
 
149
+ def _substitute_sku(items, re, sub)
150
+ items.map { |i| LineItem.new(i.sku_fragment.gsub(re, sub), i.quantity) }
151
+ end
138
152
 
139
- def pack(recipes, items)
140
- final_result = items
141
-
142
- # keep trying to 'pack' until it stabilises (e.g., 6x6pack => 1x24pack+2x6pack => 1x24pack+1x12pack)
143
- loop do
144
- new_result = pack_single_step(recipes, final_result, unpacking: false)
145
- # no changes, break out
146
- break if new_result.map { |li| [li.sku, li.quantity] }.sort == final_result.map { |li| [li.sku, li.quantity] }.sort
147
-
148
- final_result = new_result
153
+ def _pack_single_step(recipes, items, unpacking:)
154
+ recipe = Recipe::find_best_recipe(recipes, items, unpacking: unpacking)
155
+ if recipe.nil?
156
+ # its as packed/unpacked as it can get
157
+ return items
149
158
  end
150
- final_result
159
+
160
+ before = LineItem::summarise(items)
161
+ items = Recipe::apply_recipe(recipe, items)
162
+ after = LineItem::summarise(items)
163
+ logger.debug "applying recipe (#{unpacking ? 'unpacking' : 'packing'}) #{Recipe::summarise(recipe)}: #{before} -> #{after}"
164
+ items
151
165
  end
152
- end
153
- end
154
- end
166
+
167
+ end # class << self
168
+ end # module Packing
169
+ end # module Seshbot
@@ -2,59 +2,127 @@ module Seshbot
2
2
  module Packing
3
3
  module Recipe
4
4
  class << self
5
- def find_best_recipe(recipes_hash, sku_fragment, qty, unpacking: false)
6
- recipes = find_recipes(recipes_hash, sku_fragment, qty, unpacking: unpacking)
7
- # we want the recipe that is 'best' (packaging into as few items as possible, or unpacaging into as many as possible)
8
- best_recipe_quantities = nil
9
- recipes.each do |name, recipe|
10
- recipe_quantities = apply_recipe(recipe, qty)
5
+ def find_best_recipe(recipes_hash, items, unpacking: false)
6
+ recipes = _find_recipes(recipes_hash, items, unpacking: unpacking)
11
7
 
12
- # if there was no 'best' until now, just set it (makes the next step a bit simpler)
13
- best_recipe_quantities ||= recipe_quantities
8
+ # we want the recipe that is 'best' (packaging into as few items as possible, or unpacaging into as many as possible)
9
+ best_recipe = nil
10
+ best_recipe_output_quantity = nil
11
+ recipes.each do |recipe_name, recipe|
12
+ recipe_factor = _calculate_recipe_factor(recipe, items)
13
+ recipe_output_quantity = recipe_factor * recipe['output_quantity']
14
14
 
15
15
  # is this recipe the 'best'? (if packaging, best is the one that creates fewest quantity)
16
- is_best = unpacking ?
17
- recipe_quantities[:output_quantity] > best_recipe_quantities[:output_quantity] :
18
- recipe_quantities[:output_quantity] < best_recipe_quantities[:output_quantity]
16
+ is_best = if best_recipe_output_quantity.nil?
17
+ true
18
+ else
19
+ unpacking ?
20
+ recipe_output_quantity > best_recipe_output_quantity :
21
+ recipe_output_quantity < best_recipe_output_quantity
22
+ end
19
23
 
20
24
  if is_best
21
- best_recipe_quantities = recipe_quantities
25
+ best_recipe_output_quantity = recipe_output_quantity
26
+ best_recipe = recipe
22
27
  end
23
28
  end
24
- best_recipe_quantities[:recipe] if best_recipe_quantities
29
+ best_recipe
25
30
  end
26
31
 
27
- def find_recipes(recipes, sku_fragment, qty, unpacking: false)
28
- recipes = reverse_recipes(recipes) if unpacking
29
- recipes.select do |name, recipe|
30
- recipe["input_fragment"] == sku_fragment && qty >= recipe["input_quantity"]
32
+ def apply_recipe(recipe, items)
33
+ factor = _calculate_recipe_factor(recipe, items)
34
+
35
+ # shortcut - cannot apply recipe, not enough inputs
36
+ return items if factor == 0
37
+
38
+ results = items.dup
39
+
40
+ # first remove inputs (add negative quantities)
41
+ recipe_inputs = recipe['inputs'].nil? ? [recipe] : recipe['inputs']
42
+ recipe_inputs.each do |inputs|
43
+ results << LineItem.new(inputs['input_fragment'], -1 * inputs['input_quantity'] * factor)
31
44
  end
32
- # recipes.select { |name, recipe| recipe[:input_fragment] == sku_fragment && qty >= recipe[:input_quantity] }
45
+
46
+ # now add input
47
+ results << LineItem.new(recipe['output_fragment'], recipe['output_quantity'] * factor)
48
+
49
+ # consolidate
50
+ results = LineItem.merge_line_items(results)
33
51
  end
34
52
 
35
- def reverse_recipes(recipes)
36
- results = recipes.map do |recipe_name, recipe_details|
53
+ def summarise(recipe)
54
+ recipe_inputs = recipe['inputs'].nil? ? [recipe] : recipe['inputs']
55
+ inputs = recipe_inputs.map { |i| "#{i['input_quantity']}x#{i['input_fragment']}" }.join(',')
56
+ outputs = "#{recipe['output_quantity']}x#{recipe['output_fragment']}"
57
+ "#{inputs} -> #{outputs}"
58
+ end
59
+
60
+ #
61
+ # private
62
+ #
63
+
64
+ def _calculate_recipe_factor(recipe, items)
65
+ recipe_inputs = recipe['inputs'].nil? ? [recipe] : recipe['inputs']
66
+
67
+ recipe_input_factors = recipe_inputs.map do |inputs|
68
+ input_fragment = inputs['input_fragment']
69
+ input_quantity = inputs['input_quantity']
70
+
71
+ item = items.find { |i| i.sku_fragment == input_fragment }
72
+ raise "recipe contains items not present in order (#{input_fragment})" if item.nil?
73
+
74
+ [inputs, item.quantity / input_quantity]
75
+ end
76
+
77
+ recipe_input_factors.map { |i,f| f }.min
78
+ end
79
+
80
+ def _find_recipes(recipes, items, unpacking: false)
81
+ recipes = _reverse_recipes(recipes) if unpacking
82
+ recipes.select do |recipe_name, recipe_details|
83
+ recipe_inputs = recipe_details['inputs'].nil? ? [recipe_details] : recipe_details['inputs']
84
+
85
+ # recipe is a candidate if all inputs may be satisfied by available items
86
+ recipe_inputs.all? do |inputs|
87
+ items.any? { |i| inputs["input_fragment"] == i.sku_fragment && i.quantity >= inputs["input_quantity"] }
88
+ end
89
+ end
90
+ end
91
+
92
+ def _reverse_recipes(recipes)
93
+ # first exclude recipes that are explicitly configured as not reversible
94
+ reversible_recipes = recipes.select do |recipe_name, recipe_details|
95
+ recipe_details['is_reversible'].nil? || recipe_details['is_reversible']
96
+ end
97
+
98
+ unique_reversible_output_fragments = reversible_recipes.values.map { |r| r['output_fragment'] }.sort.uniq
99
+
100
+ # further refine by excluding recipes that are technically incapable of being reversed
101
+ reversible_recipes = reversible_recipes.select do |recipe_name, recipe_details|
102
+ # currently dont support multipe outputs, so cannot reverse recipes with multiple inputs
103
+ is_composite = !recipe_details['inputs'].nil? && recipe_details['inputs'].length != 1
104
+ # cannot reverse recipes where there are multipe ways of creating the same output
105
+ is_ambiguous = !unique_reversible_output_fragments.include?(recipe_details['output_fragment'])
106
+
107
+ !is_composite && !is_ambiguous
108
+ end
109
+
110
+ results = reversible_recipes.map do |recipe_name, recipe_details|
111
+ # here we know that if 'inputs' is specified it will have exactly 1 input
112
+ input_fragment = recipe_details["input_fragment"] || recipe_details["inputs"][0]["input_fragment"]
113
+ input_quantity = recipe_details["input_quantity"] || recipe_details["inputs"][0]["input_quantity"]
114
+
37
115
  new_recipe_details = {
38
116
  "input_fragment" => recipe_details["output_fragment"],
39
117
  "input_quantity" => recipe_details["output_quantity"],
40
- "output_fragment" => recipe_details["input_fragment"],
41
- "output_quantity" => recipe_details["input_quantity"]
118
+ "output_fragment" => input_fragment,
119
+ "output_quantity" => input_quantity
42
120
  }
43
121
  [recipe_name, new_recipe_details]
44
122
  end
45
123
  results.to_h
46
124
  end
47
-
48
- def apply_recipe(recipe, input_quantity)
49
- multiplier, remainder = input_quantity.divmod(recipe["input_quantity"])
50
- {
51
- recipe: recipe,
52
- input_quantity: remainder,
53
- output_quantity: multiplier * recipe["output_quantity"]
54
- }
55
- end
56
-
57
- end
58
- end
59
- end
60
- end
125
+ end # class << self
126
+ end # module Recipe
127
+ end # module Packing
128
+ end # module Seshbot
@@ -1,5 +1,5 @@
1
1
  module Seshbot
2
2
  module Packing
3
- VERSION = '0.8.6'
3
+ VERSION = '0.9.1'
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: seshbot-packing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.6
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shaun
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-03-31 00:00:00.000000000 Z
11
+ date: 2021-04-07 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email: