seshbot-packing 0.8.5 → 0.9.4

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: 1df1737c878348a458089c20c950a5a2c0a8fdd501736904f8a4c89cc83e92ad
4
- data.tar.gz: bb96a3429acfbda5e066652600df576f6cf3a0a4e303ee3c6a68a1841eb984e8
3
+ metadata.gz: 81b8dec69884aa8fe05b60a8541246a1c7c72876a0a1e5870baa5c017797097e
4
+ data.tar.gz: b2df5af90b433af11660e0c73827a4f0be65d35e6abe0fec7a402ede54dd77d6
5
5
  SHA512:
6
- metadata.gz: 8557f78b9e1092f5ea8d776399d2ebc9b1163cd184a45e28482abf2635788b57e27f7dc6fd7d32e7b6b013eb62f86dddb71f3ff64c77f74e71c36278a24e6403
7
- data.tar.gz: 52d32265eedae66704b42a43a6183a47bf4268007b463a6cc2529cc291b09c10ef5353b15b725add93931d7f273053fbfc46700a70e43091218b8106017f9e4d
6
+ metadata.gz: c52c6926758c54f7eaf8465e5b95c9357cdb1210b36d00d9ae2b11d02824a5e5d63b686eac9b60b4c77d044f5f6348bc8dcb7472e34adceb2c11721b335ff3c1
7
+ data.tar.gz: d4213856cfdc2364c24f05b447ababaa6c4003d195f78d083bbd4d735a5986f56911e93c6793d8f12817360e4f8170177a1e1aafe27d1b0e436a07bf70ff242a
data/.gitignore CHANGED
@@ -9,3 +9,12 @@
9
9
  /spec/reports/
10
10
  /tmp/
11
11
  *.gem
12
+
13
+ # Ignore all logfiles and tempfiles.
14
+ /log/*
15
+ !/log/.keep
16
+ !/tmp/.keep
17
+
18
+ .idea/
19
+ .DS_Store
20
+ ngrok
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.5)
4
+ seshbot-packing (0.9.3)
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
 
@@ -1,3 +1,4 @@
1
1
  require "seshbot/packing/version"
2
2
  require "seshbot/packing/package"
3
3
  require "seshbot/packing/recipe"
4
+ require "seshbot/packing/line_item"
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seshbot
4
+ module Packing
5
+ # :nodocs:
6
+ class LineItem
7
+ attr_accessor :sku_fragment, :quantity, :price
8
+
9
+ def initialize(sku_fragment, quantity, price=0)
10
+ raise 'SKU fragment must be a string' if sku_fragment.class != String
11
+ raise 'Quantity must be an integer' if quantity.class != Integer
12
+
13
+ @sku_fragment = sku_fragment
14
+ @quantity = quantity
15
+ @price = price
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
39
+ end
40
+ end # module Packing
41
+ end # module Seshbot
@@ -5,148 +5,194 @@ module Seshbot
5
5
  module Packing
6
6
  class Error < StandardError; end
7
7
  class << self
8
+ @@logger = nil
8
9
  def logger
9
- logger = Logger.new(STDERR)
10
- logger.level = 1
11
- logger
10
+ if @@logger.nil?
11
+ if defined?(Rails)
12
+ @@logger = Rails.logger
13
+ else
14
+ @@logger = Logger.new(STDERR)
15
+ @@logger.level = 1
16
+ end
17
+ end
18
+ @@logger
12
19
  end
13
20
 
14
- def filter_by_sku(items, sku_fragment, inverse: false)
15
- items.select do |i|
16
- matches = i['sku'].include?(sku_fragment)
17
- inverse ? !matches : matches
21
+ def bundle_items(recipes, items, fulfilled_at: nil)
22
+ if !fulfilled_at.nil? && fulfilled_at < DateTime.parse('2020-01-14T08:00:00+09:00')
23
+ logger.debug "bundling - Not Bundling (pre-2020 order) =="
24
+ return items
18
25
  end
19
- end
26
+ is_legacy = _is_legacy(fulfilled_at)
27
+ is_legacy_str = is_legacy ? " (LEGACY)" : ""
20
28
 
21
- def substitute_sku(items, from, to)
22
- items.each { |i| i['sku'].gsub!(from, to) }
23
- items
24
- end
29
+ logger.debug "bundling - Bundling Items: #{items.map(&:to_s)}. Fulfilled at: #{fulfilled_at || '(no fulfillment date)'}#{is_legacy_str}"
30
+
31
+ results = []
32
+
33
+ if is_legacy
34
+ results = _bundle_items_legacy(recipes, items)
35
+ else
36
+ phases = recipes.values.map { |r| r['phase'] || 0 }.map(&:to_i).uniq.sort
25
37
 
26
- def count_product_types(items)
27
- result = Hash.new(0)
28
- # create a hash were the keys are sku_fragment, and the values are the summed quantities
29
- items.each do |item|
30
- size = packaged_size(item["sku"])
31
- result[size] += item["quantity"]
38
+ results = items
39
+ phases.each do |phase|
40
+ phase_recipes = recipes.select { |name, recipe| (recipe['phase'] || 0) == phase }.to_h
41
+
42
+ logger.debug "bundling - PHASE #{phase}"
43
+
44
+ results = _bundle_items_single_phase(phase_recipes, results)
45
+ end
32
46
  end
33
- result
47
+
48
+ logger.debug "bundling - Bundled result:"
49
+ logger.debug " - IN: #{items.map(&:to_s)}"
50
+ logger.debug " - OUT: #{results.map(&:to_s)}"
51
+
52
+ results
34
53
  end
35
54
 
36
- def packaged_size(sku)
37
- size_regex = /-(K?[LUP]\d{2}|[BCM]\d{3})/
38
- match = sku.match(size_regex)
39
- raise "Cannot determine packaged size for SKU: #{sku}" if match.nil? || match.captures.empty?
55
+ def unbundle_items(recipes, items, fulfilled_at: nil)
56
+ logger.debug "bundling - Unbundling Items: #{items.map(&:to_s)}"
40
57
 
41
- # this is everything inside the (..) in the regex (everything except the leading '-')
42
- result = match.captures[0]
43
- # HACK! for legacy reasons, we don't consider K to be part of the size.
44
- result.gsub!(/^K/, '')
58
+ results = []
45
59
 
46
- if result == "B502"
47
- result = "B306"
48
- end
49
- if result == "B504"
50
- result = "B312"
60
+ phases = recipes.values.map { |r| r['phase'] || 0 }.map(&:to_i).uniq.sort.reverse
61
+
62
+ results = items
63
+ phases.each do |phase|
64
+ phase_recipes = recipes.select { |name, recipe| (recipe['phase'] || 0) == phase }.to_h
65
+
66
+ logger.debug "bundling - PHASE #{phase}"
67
+
68
+ # get a list of items merged together by variant type (e.g., 10xBIGI-C301 + 5xBISS-C301 => 15xBUND-C301))
69
+ merged_items = LineItem.merge_line_items(results)
70
+ results = unpack(recipes, merged_items)
51
71
  end
52
- raise "Cannot determine packaged size for SKU: #{sku}" if result.nil?
53
72
 
54
- result
73
+ logger.debug "bundling - Unbundle result:"
74
+ logger.debug " - IN: #{items.map(&:to_s)}"
75
+ logger.debug " - OUT: #{results.map(&:to_s)}"
76
+
77
+ results
55
78
  end
56
79
 
57
- def bundle_items(recipes, items, fulfilled_at: nil)
58
- logger.debug "***** BUNDLING"
59
- logger.debug "Bundling Items: #{items}. Fulfilled at: #{fulfilled_at || '(no fulfillment date)'}"
60
- if !fulfilled_at.nil? && fulfilled_at < DateTime.new(2020, 1, 14, 8, 0, 0, Time.new.zone)
61
- return items.map { |item| { 'sku' => item.sku, 'quantity' => item.quantity, 'price' => item.price } }
80
+ def unpack(recipes, items)
81
+ prev_result = items
82
+
83
+ (1..1000).each do |i|
84
+ new_result = _pack_single_step(recipes, prev_result, unpacking: true)
85
+ is_unchanged = new_result.map { |li| [li.sku_fragment, li.quantity] }.sort == prev_result.map { |li| [li.sku_fragment, li.quantity] }.sort
86
+ return new_result if is_unchanged
87
+
88
+ prev_result = new_result
89
+ end
90
+ error_message = "bundling - could not unpack - infinite loop? (latest: #{prev_result.map(&:to_s)})"
91
+ logger.error error_message
92
+ raise error_message
93
+ end
94
+
95
+ def pack(recipes, items)
96
+ prev_result = items
97
+
98
+ # keep trying to 'pack' until it stabilises (e.g., 6x6pack => 1x24pack+2x6pack => 1x24pack+1x12pack)
99
+ (1..1000).each do |i|
100
+ new_result = _pack_single_step(recipes, prev_result, unpacking: false)
101
+ # no changes, break out
102
+ is_unchanged = new_result.map { |li| [li.sku_fragment, li.quantity] }.sort == prev_result.map { |li| [li.sku_fragment, li.quantity] }.sort
103
+ return new_result if is_unchanged
104
+
105
+ prev_result = new_result
62
106
  end
107
+ error_message = "bundling - could not pack - infinite loop? (latest: #{prev_result.map(&:to_s)})"
108
+ logger.error error_message
109
+ raise error_message
110
+ end
111
+
112
+ #
113
+ # private
114
+ #
63
115
 
116
+ def _is_legacy(fulfilled_at)
117
+ effective_fulfilled_at = fulfilled_at.nil? ? DateTime.now : fulfilled_at
118
+ is_legacy = effective_fulfilled_at < DateTime.parse('2021-04-23T08:00:00+09:00')
119
+ is_legacy
120
+ end
121
+
122
+ def _bundle_items_legacy(recipes, items)
64
123
  # get the cans first, and separate them out
65
- cans = filter_by_sku(items, '-C')
124
+ cans = _filter_by_sku_fragment_prefix(items, 'C')
125
+ remaining_cans = []
66
126
  unless cans.empty?
67
- product_type_counts = count_product_types(cans)
68
- product_type_counts = unpack(recipes, product_type_counts)
69
- product_type_counts = pack(recipes, product_type_counts)
70
- new_can_items = product_type_counts.map { |k, v| { 'sku' => "BUND-#{k}", 'quantity' => v, 'price' => 0 } }
127
+ bundle_items_by_type = LineItem.merge_line_items(cans)
128
+ bundle_items_by_type = unpack(recipes, bundle_items_by_type)
129
+ new_can_items = pack(recipes, bundle_items_by_type)
71
130
  # separate out all the C324s
72
- separated_c324s = filter_by_sku(new_can_items, '-C324')
73
- logger.debug "Cans: #{cans}"
74
- logger.debug " - Removed C324s: #{separated_c324s}"
131
+ separated_c324s = _filter_by_sku_fragment_prefix(new_can_items, 'C324')
132
+ logger.debug "bundling - Cans: #{cans.map(&:to_s)}"
133
+ logger.debug " - Removed C324s: #{separated_c324s.map(&:to_s)}"
75
134
 
76
- remaining_cans = filter_by_sku(new_can_items, '-C324', inverse: true)
77
- logger.debug " - Remaining skus: #{remaining_cans}"
135
+ remaining_cans = _filter_by_sku_fragment_prefix(new_can_items, 'C324', inverse: true)
136
+ logger.debug " - Remaining skus: #{remaining_cans.map(&:to_s)}"
78
137
  end
79
138
 
80
139
  # merge the remaining with the original leftover
81
- non_cans = filter_by_sku(items, '-C', inverse: true)
82
- remaining_cans ||= []
140
+ non_cans = _filter_by_sku_fragment_prefix(items, 'C', inverse: true)
83
141
  remaining_items = non_cans + remaining_cans
84
- logger.debug "Substituting:"
85
- logger.debug " - remaining items: #{remaining_items}"
142
+ logger.debug "bundling - Substituting:"
143
+ logger.debug " - remaining items: #{remaining_items.map(&:to_s)}"
86
144
 
87
145
  # substitute C's for B's
88
- substitute_sku(remaining_items, '-C', '-B')
89
- logger.debug " - skus updated to: #{remaining_items}"
146
+ remaining_items = _substitute_sku(remaining_items, /^C/, 'B')
147
+ logger.debug " - skus updated to: #{remaining_items.map(&:to_s)}"
90
148
 
91
149
  # get a hash of {pack_6: 5, pack_12: 1}
92
- product_type_counts = count_product_types(remaining_items)
150
+ bundle_items_by_type = LineItem.merge_line_items(remaining_items)
93
151
  # dismantle packages into individual units (e.g., {pack_6: 7})
94
- product_type_counts = unpack(recipes, product_type_counts)
152
+ bundle_items_by_type = unpack(recipes, bundle_items_by_type)
95
153
  # repackage into the 'best' packaging we can figure out (e.g., {pack_12: 2})
96
- product_type_counts = pack(recipes, product_type_counts)
97
- new_remaining_items = product_type_counts.map { |k,v| { "sku" => "BUND-#{k}", "quantity" => v, "price" => 0} }
154
+ new_remaining_items = pack(recipes, bundle_items_by_type)
98
155
 
99
156
  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
106
-
107
-
108
- def unpack(recipes, product_type_counts)
109
- final_result = product_type_counts
110
-
111
- while true
112
- new_result = pack_single_step(recipes, final_result, unpacking: true)
113
- break if new_result == final_result
157
+ results = new_remaining_items + separated_c324s
114
158
 
115
- final_result = new_result
116
- end
117
- final_result
159
+ results
118
160
  end
119
161
 
120
- def pack_single_step(recipes, product_type_counts, unpacking:)
121
- result = Hash.new(0)
122
- product_type_counts.each do |sku_fragment, qty|
123
- recipe = Recipe::find_best_recipe(recipes, sku_fragment, qty, unpacking: unpacking)
124
- if recipe.nil?
125
- result[sku_fragment] += qty
126
- next
127
- end
162
+ def _bundle_items_single_phase(recipes, items)
163
+ # get a list of items merged together by variant type (e.g., 10xBIGI-C301 + 5xBISS-C301 => 15xBUND-C301))
164
+ merged_items = LineItem.merge_line_items(items)
165
+ # dismantle packages into individual units
166
+ unpacked_items = unpack(recipes, merged_items)
167
+ # repackage into the 'best' packaging we can figure out
168
+ pack(recipes, unpacked_items)
169
+ end
128
170
 
129
- recipe_quantities = Recipe::apply_recipe(recipe, qty)
130
- result[recipe["input_fragment"]] += recipe_quantities[:input_quantity]
131
- result[recipe["output_fragment"]] += recipe_quantities[:output_quantity]
171
+ def _filter_by_sku_fragment_prefix(items, sku_fragment, inverse: false)
172
+ items.select do |i|
173
+ matches = i.sku_fragment.start_with? sku_fragment
174
+ inverse ? !matches : matches
132
175
  end
133
- result.delete_if { |k,v| v == 0}
134
- result
135
176
  end
136
177
 
178
+ def _substitute_sku(items, re, sub)
179
+ items.map { |i| LineItem.new(i.sku_fragment.gsub(re, sub), i.quantity) }
180
+ end
137
181
 
138
- def pack(recipes, product_type_counts)
139
- final_result = product_type_counts
140
-
141
- # keep trying to 'pack' until it stabilises (e.g., 6x6pack => 1x24pack+2x6pack => 1x24pack+1x12pack)
142
- while true
143
- new_result = pack_single_step(recipes, final_result, unpacking: false)
144
- # no changes, break out
145
- break if new_result == final_result
146
- final_result = new_result
182
+ def _pack_single_step(recipes, items, unpacking:)
183
+ recipe = Recipe::find_best_recipe(recipes, items, unpacking: unpacking)
184
+ if recipe.nil?
185
+ # its as packed/unpacked as it can get
186
+ return items
147
187
  end
148
- final_result
188
+
189
+ before = LineItem::summarise(items)
190
+ items = Recipe::apply_recipe(recipe, items)
191
+ after = LineItem::summarise(items)
192
+ logger.debug "bundling - applying recipe (#{unpacking ? 'unpacking' : 'packing'}) #{Recipe::summarise(recipe)}: #{before} -> #{after}"
193
+ items
149
194
  end
150
- end
151
- end
152
- end
195
+
196
+ end # class << self
197
+ end # module Packing
198
+ end # module Seshbot
@@ -2,59 +2,146 @@ 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)
5
+ def find_best_recipe(recipes_hash, items, unpacking: false)
6
+ recipes = _find_recipes(recipes_hash, items, unpacking: unpacking)
7
+
7
8
  # 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)
9
+ best_recipe = nil
10
+ best_recipe_output_quantity = nil
11
+ best_recipe_inputs_quantity = nil
12
+ recipes.each do |recipe_name, recipe|
13
+ recipe_inputs = recipe['inputs'].nil? ? [recipe] : recipe['inputs']
11
14
 
12
- # if there was no 'best' until now, just set it (makes the next step a bit simpler)
13
- best_recipe_quantities ||= recipe_quantities
15
+ recipe_factor = _calculate_recipe_factor(recipe, items)
16
+ recipe_output_quantity = recipe_factor * recipe['output_quantity']
17
+ recipe_inputs_quantity = recipe_factor * recipe_inputs.map { |r| r['input_quantity'] }.sum
14
18
 
15
19
  # 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]
20
+ is_best = if best_recipe_output_quantity.nil?
21
+ true
22
+ else
23
+ #
24
+ # the below code uses output as the main feature, but if they are equal uses the number of inputs
25
+ #
26
+
27
+ output_equal = recipe_output_quantity == best_recipe_output_quantity
28
+
29
+ # when packing, fewer outputs are better
30
+ output_better = unpacking ?
31
+ recipe_output_quantity > best_recipe_output_quantity :
32
+ recipe_output_quantity < best_recipe_output_quantity
33
+
34
+ # when packing, more inputs are better
35
+ inputs_better = unpacking ?
36
+ recipe_inputs_quantity < best_recipe_inputs_quantity :
37
+ recipe_inputs_quantity > best_recipe_inputs_quantity
38
+
39
+ output_equal ? inputs_better : output_better
40
+ end
19
41
 
20
42
  if is_best
21
- best_recipe_quantities = recipe_quantities
43
+ best_recipe_output_quantity = recipe_output_quantity
44
+ best_recipe_inputs_quantity = recipe_inputs_quantity
45
+ best_recipe = recipe
22
46
  end
23
47
  end
24
- best_recipe_quantities[:recipe] if best_recipe_quantities
48
+ best_recipe
49
+ end
50
+
51
+ def apply_recipe(recipe, items)
52
+ factor = _calculate_recipe_factor(recipe, items)
53
+
54
+ # shortcut - cannot apply recipe, not enough inputs
55
+ return items if factor == 0
56
+
57
+ results = items.dup
58
+
59
+ # first remove inputs (add negative quantities)
60
+ recipe_inputs = recipe['inputs'].nil? ? [recipe] : recipe['inputs']
61
+ recipe_inputs.each do |inputs|
62
+ results << LineItem.new(inputs['input_fragment'], -1 * inputs['input_quantity'] * factor)
63
+ end
64
+
65
+ # now add input
66
+ results << LineItem.new(recipe['output_fragment'], recipe['output_quantity'] * factor)
67
+
68
+ # consolidate
69
+ results = LineItem.merge_line_items(results)
70
+ end
71
+
72
+ def summarise(recipe)
73
+ recipe_inputs = recipe['inputs'].nil? ? [recipe] : recipe['inputs']
74
+ inputs = recipe_inputs.map { |i| "#{i['input_quantity']}x#{i['input_fragment']}" }.join(',')
75
+ outputs = "#{recipe['output_quantity']}x#{recipe['output_fragment']}"
76
+ "#{inputs} -> #{outputs}"
77
+ end
78
+
79
+ #
80
+ # private
81
+ #
82
+
83
+ def _calculate_recipe_factor(recipe, items)
84
+ recipe_inputs = recipe['inputs'].nil? ? [recipe] : recipe['inputs']
85
+
86
+ recipe_input_factors = recipe_inputs.map do |inputs|
87
+ input_fragment = inputs['input_fragment']
88
+ input_quantity = inputs['input_quantity']
89
+
90
+ item = items.find { |i| i.sku_fragment == input_fragment }
91
+ raise "recipe contains items not present in order (#{input_fragment})" if item.nil?
92
+
93
+ [inputs, item.quantity / input_quantity]
94
+ end
95
+
96
+ recipe_input_factors.map { |i,f| f }.min
25
97
  end
26
98
 
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"]
99
+ def _find_recipes(recipes, items, unpacking: false)
100
+ recipes = _reverse_recipes(recipes) if unpacking
101
+ recipes.select do |recipe_name, recipe_details|
102
+ recipe_inputs = recipe_details['inputs'].nil? ? [recipe_details] : recipe_details['inputs']
103
+
104
+ # recipe is a candidate if all inputs may be satisfied by available items
105
+ recipe_inputs.all? do |inputs|
106
+ items.any? { |i| inputs["input_fragment"] == i.sku_fragment && i.quantity >= inputs["input_quantity"] }
107
+ end
31
108
  end
32
- # recipes.select { |name, recipe| recipe[:input_fragment] == sku_fragment && qty >= recipe[:input_quantity] }
33
109
  end
34
110
 
35
- def reverse_recipes(recipes)
36
- results = recipes.map do |recipe_name, recipe_details|
111
+ def _reverse_recipes(recipes)
112
+ # first exclude recipes that are explicitly configured as not reversible
113
+ reversible_recipes = recipes.select do |recipe_name, recipe_details|
114
+ recipe_details['is_reversible'].nil? || recipe_details['is_reversible']
115
+ end
116
+
117
+ unique_reversible_output_fragments = reversible_recipes.values.map { |r| r['output_fragment'] }.sort.uniq
118
+
119
+ # further refine by excluding recipes that are technically incapable of being reversed
120
+ reversible_recipes = reversible_recipes.select do |recipe_name, recipe_details|
121
+ # currently dont support multipe outputs, so cannot reverse recipes with multiple inputs
122
+ is_composite = !recipe_details['inputs'].nil? && recipe_details['inputs'].length != 1
123
+ # cannot reverse recipes where there are multipe ways of creating the same output
124
+ is_ambiguous = !unique_reversible_output_fragments.include?(recipe_details['output_fragment'])
125
+
126
+ !is_composite && !is_ambiguous
127
+ end
128
+
129
+ results = reversible_recipes.map do |recipe_name, recipe_details|
130
+ # here we know that if 'inputs' is specified it will have exactly 1 input
131
+ input_fragment = recipe_details["input_fragment"] || recipe_details["inputs"][0]["input_fragment"]
132
+ input_quantity = recipe_details["input_quantity"] || recipe_details["inputs"][0]["input_quantity"]
133
+
37
134
  new_recipe_details = {
38
135
  "input_fragment" => recipe_details["output_fragment"],
39
136
  "input_quantity" => recipe_details["output_quantity"],
40
- "output_fragment" => recipe_details["input_fragment"],
41
- "output_quantity" => recipe_details["input_quantity"]
137
+ "output_fragment" => input_fragment,
138
+ "output_quantity" => input_quantity
42
139
  }
43
140
  [recipe_name, new_recipe_details]
44
141
  end
45
142
  results.to_h
46
143
  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
144
+ end # class << self
145
+ end # module Recipe
146
+ end # module Packing
147
+ end # module Seshbot
@@ -1,5 +1,5 @@
1
1
  module Seshbot
2
2
  module Packing
3
- VERSION = '0.8.5'
3
+ VERSION = '0.9.4'
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.5
4
+ version: 0.9.4
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-29 00:00:00.000000000 Z
11
+ date: 2021-04-22 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -28,6 +28,7 @@ files:
28
28
  - bin/console
29
29
  - bin/setup
30
30
  - lib/seshbot/packing.rb
31
+ - lib/seshbot/packing/line_item.rb
31
32
  - lib/seshbot/packing/package.rb
32
33
  - lib/seshbot/packing/recipe.rb
33
34
  - lib/seshbot/packing/version.rb