seshbot-packing 0.8.2 → 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: 7336679900a9b2f7950d7af79692a4a1fa42c89d88b7b3e7e9932a564a0913c8
4
- data.tar.gz: 4e3ced53b824add98949a55290e80c411f929b1c9032948214de06bac6107ce1
3
+ metadata.gz: 5a3ee88fef7cdb7044eb06951aaee1a388dc499a33226d553ecf1d4af3865df2
4
+ data.tar.gz: 7ceb6a0a5ad57b515e881352907e42c3bec29330c1055a8947e9662f03ad0ebc
5
5
  SHA512:
6
- metadata.gz: 717491a4886d9ae93e5e68f9036aeeea39aa50752718e77e2a6bfdba81a794944fd3d48a5aa4f32dad48e8385bcaa327ee662026b3e241f6ed66fd12f6c59d41
7
- data.tar.gz: ba70ad1c512f59b905b560414efe5ed7e31a8cc69ad971e7fa402cc4fb2c5b493da40044162eb5864f958ee205ea43ccf94dd94837591eb71a60d67d79c69098
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.1)
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
 
@@ -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,144 +5,165 @@ 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.new(STDERR)
10
+ if @@logger.nil?
11
+ @@logger = Logger.new(STDERR)
12
+ @@logger.level = 1
13
+ end
14
+ @@logger
10
15
  end
11
16
 
12
- def filter_by_sku(items, sku_fragment, inverse: false)
13
- items.select do |i|
14
- matches = i['sku'].include?(sku_fragment)
15
- 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
16
21
  end
17
- end
22
+ is_legacy = _is_legacy(fulfilled_at)
23
+ is_legacy_str = is_legacy ? " (LEGACY)" : ""
18
24
 
19
- def substitute_sku(items, from, to)
20
- items.each { |i| i['sku'].gsub!(from, to) }
21
- items
22
- end
25
+ logger.debug "== Bundling Items: #{items.map(&:to_s)}. Fulfilled at: #{fulfilled_at || '(no fulfillment date)'}#{is_legacy_str}"
23
26
 
24
- def count_product_types(items)
25
- result = Hash.new(0)
26
- # create a hash were the keys are sku_fragment, and the values are the summed quantities
27
- items.each do |item|
28
- size = packaged_size(item["sku"])
29
- 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
30
42
  end
31
- result
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
32
49
  end
33
50
 
34
- def packaged_size(sku)
35
- size_regex = /-([LUP]\d{2}|[BCM]\d{3})/
36
- match = sku.match(size_regex)
37
- raise "Cannot determine packaged size for SKU: #{sku}" if match.nil? || match.captures.empty?
51
+ def unpack(recipes, items)
52
+ prev_result = items
38
53
 
39
- # this is everything inside the (..) in the regex (everything except the leading '-')
40
- result = match.captures[0]
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
41
58
 
42
- if result == "B502"
43
- result = "B306"
59
+ prev_result = new_result
44
60
  end
45
- if result == "B504"
46
- result = "B312"
47
- end
48
- raise "Cannot determine packaged size for SKU: #{sku}" if result.nil?
49
-
50
- result
61
+ error_message = "could not unpack - infinite loop? (latest: #{prev_result.map(&:to_s)})"
62
+ logger.error error_message
63
+ raise error_message
51
64
  end
52
65
 
53
- def bundle_items(items, fulfilled_at: nil)
54
- logger.debug "***** BUNDLING"
55
- logger.debug "Bundling Items: #{items}. Fulfilled at: #{fulfilled_at || '(no fulfillment date)'}"
56
- if !fulfilled_at.nil? && fulfilled_at < DateTime.new(2020, 1, 14, 8, 0, 0, Time.new.zone)
57
- return items.map { |item| { 'sku' => item.sku, 'quantity' => item.quantity, 'price' => item.price } }
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
58
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
59
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
92
+
93
+ def _bundle_items_legacy(recipes, items)
60
94
  # get the cans first, and separate them out
61
- cans = filter_by_sku(items, '-C')
95
+ cans = _filter_by_sku_fragment_prefix(items, 'C')
96
+ remaining_cans = []
62
97
  unless cans.empty?
63
- product_type_counts = count_product_types(cans)
64
- product_type_counts = unpack(product_type_counts)
65
- product_type_counts = pack(product_type_counts)
66
- new_can_items = product_type_counts.map { |k, v| { 'sku' => "BUND-#{k}", 'quantity' => v, 'price' => 0 } }
98
+ bundle_items_by_type = LineItem.merge_line_items(cans)
99
+ bundle_items_by_type = unpack(recipes, bundle_items_by_type)
100
+ new_can_items = pack(recipes, bundle_items_by_type)
67
101
  # separate out all the C324s
68
- separated_c324s = filter_by_sku(new_can_items, '-C324')
69
- logger.debug "Cans: #{cans}"
70
- 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)}"
71
105
 
72
- remaining_cans = filter_by_sku(new_can_items, '-C324', inverse: true)
73
- 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)}"
74
108
  end
75
109
 
76
110
  # merge the remaining with the original leftover
77
- non_cans = filter_by_sku(items, '-C', inverse: true)
78
- remaining_cans ||= []
111
+ non_cans = _filter_by_sku_fragment_prefix(items, 'C', inverse: true)
79
112
  remaining_items = non_cans + remaining_cans
80
113
  logger.debug "Substituting:"
81
- logger.debug " - remaining items: #{remaining_items}"
114
+ logger.debug " - remaining items: #{remaining_items.map(&:to_s)}"
82
115
 
83
116
  # substitute C's for B's
84
- substitute_sku(remaining_items, '-C', '-B')
85
- 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)}"
86
119
 
87
120
  # get a hash of {pack_6: 5, pack_12: 1}
88
- product_type_counts = count_product_types(remaining_items)
121
+ bundle_items_by_type = LineItem.merge_line_items(remaining_items)
89
122
  # dismantle packages into individual units (e.g., {pack_6: 7})
90
- product_type_counts = unpack(product_type_counts)
123
+ bundle_items_by_type = unpack(recipes, bundle_items_by_type)
91
124
  # repackage into the 'best' packaging we can figure out (e.g., {pack_12: 2})
92
- product_type_counts = pack(product_type_counts)
93
- new_remaining_items = product_type_counts.map { |k,v| { "sku" => "BUND-#{k}", "quantity" => v, "price" => 0} }
125
+ new_remaining_items = pack(recipes, bundle_items_by_type)
94
126
 
95
127
  separated_c324s ||= []
96
- merged_items = new_remaining_items + separated_c324s
97
- logger.debug "Bundled result:"
98
- logger.debug " - IN: #{items}"
99
- logger.debug " - OUT: #{merged_items}"
100
- merged_items
101
- end
128
+ results = new_remaining_items + separated_c324s
102
129
 
103
-
104
- def unpack(product_type_counts)
105
- final_result = product_type_counts
106
-
107
- while true
108
- new_result = pack_single_step(final_result, unpacking: true)
109
- break if new_result == final_result
110
-
111
- final_result = new_result
112
- end
113
- final_result
130
+ results
114
131
  end
115
132
 
116
- def pack_single_step(product_type_counts, unpacking:)
117
- result = Hash.new(0)
118
- product_type_counts.each do |sku_fragment, qty|
119
- recipe = Recipe::find_best_recipe(sku_fragment, qty, unpacking: unpacking)
120
- if recipe.nil?
121
- result[sku_fragment] += qty
122
- next
123
- 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
124
141
 
125
- recipe_quantities = Recipe::apply_recipe(recipe, qty)
126
- result[recipe["input_fragment"]] += recipe_quantities[:input_quantity]
127
- 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
128
146
  end
129
- result.delete_if { |k,v| v == 0}
130
- result
131
147
  end
132
148
 
149
+ def _substitute_sku(items, re, sub)
150
+ items.map { |i| LineItem.new(i.sku_fragment.gsub(re, sub), i.quantity) }
151
+ end
133
152
 
134
- def pack(product_type_counts)
135
- final_result = product_type_counts
136
-
137
- # keep trying to 'pack' until it stabilises (e.g., 6x6pack => 1x24pack+2x6pack => 1x24pack+1x12pack)
138
- while true
139
- new_result = pack_single_step(final_result, unpacking: false)
140
- # no changes, break out
141
- break if new_result == final_result
142
- 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
143
158
  end
144
- 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
145
165
  end
146
- end
147
- end
148
- end
166
+
167
+ end # class << self
168
+ end # module Packing
169
+ end # module Seshbot
@@ -2,113 +2,127 @@ module Seshbot
2
2
  module Packing
3
3
  module Recipe
4
4
  class << self
5
- def get_recipes
6
- {
7
- "bottles_twenty_four_pack_4"=>{
8
- "input_fragment"=>"B306",
9
- "input_quantity"=>4,
10
- "output_fragment"=>"B324",
11
- "output_quantity"=>1
12
- },
13
- "bottles_twenty_four_pack_3"=>{
14
- "input_fragment"=>"B306",
15
- "input_quantity"=>3,
16
- "output_fragment"=>"B318",
17
- "output_quantity"=>1
18
- },
19
- "bottles_twelve_pack"=>{
20
- "input_fragment"=>"B306",
21
- "input_quantity"=>2,
22
- "output_fragment"=>"B312",
23
- "output_quantity"=>1
24
- },
25
- "bottles_six_pack"=>{
26
- "input_fragment"=>"B301",
27
- "input_quantity"=>6,
28
- "output_fragment"=>"B306",
29
- "output_quantity"=>1
30
- },
31
- "cans_twenty_four_pack_4"=>{
32
- "input_fragment"=>"C306",
33
- "input_quantity"=>4,
34
- "output_fragment"=>"C324",
35
- "output_quantity"=>1
36
- },
37
- "cans_twenty_four_pack_3"=>{
38
- "input_fragment"=>"C306",
39
- "input_quantity"=>3,
40
- "output_fragment"=>"C318",
41
- "output_quantity"=>1
42
- },
43
- "cans_twelve_pack"=>{
44
- "input_fragment"=>"C306",
45
- "input_quantity"=>2,
46
- "output_fragment"=>"C312",
47
- "output_quantity"=>1
48
- },
49
- "cans_six_pack"=>{
50
- "input_fragment"=>"C301",
51
- "input_quantity"=>6,
52
- "output_fragment"=>"C306",
53
- "output_quantity"=>1
54
- }
55
- }
56
- end
5
+ def find_best_recipe(recipes_hash, items, unpacking: false)
6
+ recipes = _find_recipes(recipes_hash, items, unpacking: unpacking)
57
7
 
58
- def find_best_recipe(sku_fragment, qty, unpacking: false)
59
- recipes = find_recipes(sku_fragment, qty, unpacking: unpacking)
60
8
  # we want the recipe that is 'best' (packaging into as few items as possible, or unpacaging into as many as possible)
61
- best_recipe_quantities = nil
62
- recipes.each do |name, recipe|
63
- recipe_quantities = apply_recipe(recipe, qty)
64
-
65
- # if there was no 'best' until now, just set it (makes the next step a bit simpler)
66
- best_recipe_quantities ||= recipe_quantities
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']
67
14
 
68
15
  # is this recipe the 'best'? (if packaging, best is the one that creates fewest quantity)
69
- is_best = unpacking ?
70
- recipe_quantities[:output_quantity] > best_recipe_quantities[:output_quantity] :
71
- 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
72
23
 
73
24
  if is_best
74
- best_recipe_quantities = recipe_quantities
25
+ best_recipe_output_quantity = recipe_output_quantity
26
+ best_recipe = recipe
75
27
  end
76
28
  end
77
- best_recipe_quantities[:recipe] if best_recipe_quantities
29
+ best_recipe
78
30
  end
79
31
 
80
- def find_recipes(sku_fragment, qty, unpacking: false)
81
- recipes = get_recipes
82
- recipes = reverse_recipes(recipes) if unpacking
83
- recipes.select do |name, recipe|
84
- 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)
85
44
  end
86
- # 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)
87
51
  end
88
52
 
89
- def reverse_recipes(recipes)
90
- 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
+
91
115
  new_recipe_details = {
92
116
  "input_fragment" => recipe_details["output_fragment"],
93
117
  "input_quantity" => recipe_details["output_quantity"],
94
- "output_fragment" => recipe_details["input_fragment"],
95
- "output_quantity" => recipe_details["input_quantity"]
118
+ "output_fragment" => input_fragment,
119
+ "output_quantity" => input_quantity
96
120
  }
97
121
  [recipe_name, new_recipe_details]
98
122
  end
99
123
  results.to_h
100
124
  end
101
-
102
- def apply_recipe(recipe, input_quantity)
103
- multiplier, remainder = input_quantity.divmod(recipe["input_quantity"])
104
- {
105
- recipe: recipe,
106
- input_quantity: remainder,
107
- output_quantity: multiplier * recipe["output_quantity"]
108
- }
109
- end
110
-
111
- end
112
- end
113
- end
114
- 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.2'
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.2
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-16 00:00:00.000000000 Z
11
+ date: 2021-04-07 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