spree_boxnow 1.0.0 → 1.1.0

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: 2900ebc580d24fc4b70391b12db03014aa48254a0d412350080705e29f72d690
4
- data.tar.gz: 1abfd9b5be518d7f399712aa903ad429c5506243a5c2276e22d1f1f4ec58b725
3
+ metadata.gz: bfab3f930d7ab48d352e99628fc3c0c4eb570b9ff11c2b93dc4dbb563dba4448
4
+ data.tar.gz: 0b31a62d516f41268f2115605e8c2f295a08a57c41eca025efff102d5558d1d6
5
5
  SHA512:
6
- metadata.gz: a4326bb2f22ce5ad3864e72ba7ae3b616a3418ddf73f26252a1821b1adc531d38b7a8a61c1768f1f60ce5b848cee062814dd5465d07451821fe4b43aaef41d81
7
- data.tar.gz: edb9c3871bf6237ee19111de09a48d18d4fdc2a437ffd7140ee2a02f0f1adf01875dc7f36aacd49e6535e023b0593808982fd015b2855c87f9ae76548623f473
6
+ metadata.gz: 2c181a65bfa1938e3690d93a153a61165bb4c6dd95554461234a6b182da39d1bb330bfd4da02754e1bf4e6dc2585fc82eb1345e57c2af4689e59336b2ce5979f
7
+ data.tar.gz: e069671b98165da457d98d577db3516b495613132b977920514dfca04e0099d436e3ff78e4f8a11da243e1a633dc2750f0ec39768d347c12a325ef09b2c8eedf
@@ -7,7 +7,7 @@ module Spree
7
7
  MAX_WIDTH_CM = 45.0
8
8
  MAX_DEPTH_CM = 60.0
9
9
 
10
- # Size thresholds (height decides the size; width/depth are constant limits)
10
+ # Size thresholds tier is determined by the smallest sorted dimension
11
11
  SIZE_MAX_HEIGHT_CM = {
12
12
  small: 8.0,
13
13
  medium: 17.0,
@@ -18,8 +18,8 @@ module Spree
18
18
  preference :medium_box_price, :decimal, default: 0.0
19
19
  preference :large_box_price, :decimal, default: 0.0
20
20
 
21
- preference :base_padding_cm, :decimal, default: 1.0
22
- preference :multi_item_factor, :decimal, default: 1.05
21
+ preference :base_padding_cm, :decimal, default: 1.0
22
+ preference :multi_item_factor, :decimal, default: 1.05
23
23
 
24
24
  validates :preferred_small_box_price,
25
25
  :preferred_medium_box_price,
@@ -32,53 +32,163 @@ module Spree
32
32
  Spree.t(:shipping_boxnow_rate)
33
33
  end
34
34
 
35
+ SIZES_ORDERED = %i[small medium large].freeze
36
+
35
37
  def compute_package(package)
36
38
  return nil if package.weight.to_f > MAX_WEIGHT_KG
37
39
 
38
- dims = estimated_parcel_dimensions_cm(package)
39
- return nil if dims.nil?
40
+ boxes = minimum_bounding_boxes(package)
41
+ return nil if boxes.nil?
40
42
 
41
- dims = apply_packing_margin(dims, package)
43
+ best_size = nil
42
44
 
43
- # allow rotation by sorting dims (smallest->height threshold)
44
- s, m, d = [dims[:height], dims[:width], dims[:depth]].map(&:to_f).sort
45
- return nil if s > MAX_HEIGHT_CM || m > MAX_WIDTH_CM || d > MAX_DEPTH_CM
45
+ boxes.each do |sml|
46
+ padded = apply_packing_margin({ height: sml[0], width: sml[1], depth: sml[2] }, package)
47
+ s, m, d = [padded[:height], padded[:width], padded[:depth]].map(&:to_f).sort
48
+ next if s > MAX_HEIGHT_CM || m > MAX_WIDTH_CM || d > MAX_DEPTH_CM
46
49
 
47
- size = box_size_for_height(s)
48
- return nil unless size
50
+ size = box_size_for_height(s)
51
+ next unless size
52
+
53
+ best_size = smaller_size(best_size, size)
54
+ end
49
55
 
50
- price_for(size)
56
+ return nil unless best_size
57
+
58
+ price_for(best_size)
51
59
  end
52
60
 
53
61
  private
54
62
 
55
- # Conservative 1-parcel estimate:
56
- # - For each item: sort dims s<=m<=d
57
- # - Stack along smallest side (s) across quantities => parcel height
58
- # - Width = max(m), Depth = max(d) across all items
59
- def estimated_parcel_dimensions_cm(package)
60
- heights = []
61
- widths = []
62
- depths = []
63
-
64
- package.contents.each do |content|
65
- variant = content.variant
66
- qty = content.quantity
67
-
68
- dims = variant_dimensions_cm(variant)
69
- return nil if dims.nil?
70
-
71
- s, m, d = dims.sort
72
- heights << (s * qty)
73
- widths << m
74
- depths << d
63
+ # Exhaustive recursive bounding-box search over all item orientations and
64
+ # all possible groupings (hierarchical binary splits).
65
+ #
66
+ # Each line item (distinct SKU) is treated as a block in one of up to 3
67
+ # orientations — one per choice of which dimension is multiplied by qty.
68
+ # The solver tries every binary partition of line items into two groups,
69
+ # combines the Pareto-optimal bounding boxes of each group along 3 axes,
70
+ # and Pareto-prunes the result. Memoisation by sorted index set ensures
71
+ # each subset is computed exactly once.
72
+ #
73
+ # N = distinct line items (SKUs), not total quantity.
74
+ # 100 identical keycards = 1 line item = N=1 → trivially fast.
75
+ # For very large N (many distinct SKUs), a time-based execution guard
76
+ # can be added if observed to be slow in production.
77
+ def minimum_bounding_boxes(package)
78
+ all_items = package.contents.map do |content|
79
+ orientations = item_block_orientations(content.variant, content.quantity)
80
+ return nil if orientations.nil?
81
+ orientations
75
82
  end
76
83
 
77
- {
78
- height: heights.sum,
79
- width: widths.max || 0.0,
80
- depth: depths.max || 0.0
81
- }
84
+ return all_items[0] if all_items.size == 1
85
+
86
+ solve_boxes((0...all_items.size).to_a, all_items, {})
87
+ end
88
+
89
+ # Recursive memoised solver.
90
+ # indices — sorted Array<Integer> identifying this subset within all_items
91
+ # all_items — Array<Array<[s,m,l]>>, per-item orientation sets
92
+ # cache — Hash keyed by sorted index array
93
+ def solve_boxes(indices, all_items, cache)
94
+ return cache[indices] if cache.key?(indices)
95
+
96
+ result =
97
+ if indices.size == 1
98
+ all_items[indices[0]]
99
+ else
100
+ # Pin indices[0] in the left group and vary what else joins it.
101
+ # This enumerates every unordered binary partition exactly once.
102
+ rest = indices[1..]
103
+ combined = []
104
+
105
+ 0.upto(rest.size - 1) do |left_size|
106
+ rest.combination(left_size).each do |left_extra|
107
+ left_indices = ([indices[0]] + left_extra).sort
108
+ right_indices = (rest - left_extra).sort
109
+ next if right_indices.empty?
110
+
111
+ boxes_a = solve_boxes(left_indices, all_items, cache)
112
+ boxes_b = solve_boxes(right_indices, all_items, cache)
113
+
114
+ boxes_a.each do |a|
115
+ boxes_b.each do |b|
116
+ combined.concat(combine_sorted_boxes(a, b))
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ pareto_optimal(combined)
123
+ end
124
+
125
+ cache[indices] = result
126
+ end
127
+
128
+ # Combines two sorted triples [s1,m1,l1] and [s2,m2,l2] along each of
129
+ # the 3 axes (sum one dimension, max the other two), re-sorts each result
130
+ # to model free physical rotation, and returns the Pareto-optimal subset.
131
+ def combine_sorted_boxes(box_a, box_b)
132
+ s1, m1, l1 = box_a
133
+ s2, m2, l2 = box_b
134
+
135
+ pareto_optimal([
136
+ [s1 + s2, [m1, m2].max, [l1, l2].max].sort,
137
+ [[s1, s2].max, m1 + m2, [l1, l2].max].sort,
138
+ [[s1, s2].max, [m1, m2].max, l1 + l2 ].sort
139
+ ])
140
+ end
141
+
142
+ # Removes dominated boxes from a collection of sorted triples.
143
+ # Box X dominates box Y if X[i] <= Y[i] for all i with at least one strict.
144
+ def pareto_optimal(boxes)
145
+ boxes.reject do |candidate|
146
+ boxes.any? do |other|
147
+ next false if other.equal?(candidate)
148
+ other[0] <= candidate[0] &&
149
+ other[1] <= candidate[1] &&
150
+ other[2] <= candidate[2] &&
151
+ other != candidate
152
+ end
153
+ end.uniq
154
+ end
155
+
156
+ # Returns all distinct sorted [s,m,l] orientations for a line item.
157
+ # For each factorization of qty into (a,b,c) with a*b*c == qty, and for
158
+ # each permutation of (a,b,c) assigned to the item's 3 dimensions, the
159
+ # bounding box is computed and sorted (free rotation). This covers linear
160
+ # stacking, 2-D grids, and 3-D grids (e.g. 2x5 arrangement of 10 items).
161
+ def item_block_orientations(variant, qty)
162
+ dims = variant_dimensions_cm(variant)
163
+ return nil if dims.nil?
164
+
165
+ h, w, d = dims
166
+ orientations = []
167
+
168
+ qty_factorizations(qty).each do |a, b, c|
169
+ [a, b, c].permutation.each do |fa, fb, fc|
170
+ orientations << [h * fa, w * fb, d * fc].sort
171
+ end
172
+ end
173
+
174
+ orientations.uniq
175
+ end
176
+
177
+ # Returns all sorted triples [a, b, c] with a <= b <= c and a*b*c == n.
178
+ def qty_factorizations(n)
179
+ result = []
180
+ a = 1
181
+ while a * a * a <= n
182
+ if n % a == 0
183
+ b = a
184
+ while a * b * b <= n
185
+ result << [a, b, n / (a * b)] if (n / a) % b == 0
186
+ b += 1
187
+ end
188
+ end
189
+ a += 1
190
+ end
191
+ result
82
192
  end
83
193
 
84
194
  def apply_packing_margin(dims, package)
@@ -105,6 +215,11 @@ module Spree
105
215
  [height, width, depth]
106
216
  end
107
217
 
218
+ def smaller_size(a, b)
219
+ return b if a.nil?
220
+ SIZES_ORDERED.index(a) <= SIZES_ORDERED.index(b) ? a : b
221
+ end
222
+
108
223
  def box_size_for_height(height_cm)
109
224
  return :small if height_cm <= SIZE_MAX_HEIGHT_CM[:small]
110
225
  return :medium if height_cm <= SIZE_MAX_HEIGHT_CM[:medium]
@@ -1,5 +1,5 @@
1
1
  module SpreeBoxnow
2
- VERSION = '1.0.0'.freeze
2
+ VERSION = '1.1.0'.freeze
3
3
 
4
4
  def gem_version
5
5
  Gem::Version.new(VERSION)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_boxnow
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OlympusOne
@@ -167,10 +167,10 @@ licenses:
167
167
  - MIT
168
168
  metadata:
169
169
  bug_tracker_uri: https://github.com/olympusone/spree_boxnow/issues
170
- changelog_uri: https://github.com/olympusone/spree_boxnow/releases/tag/v1.0.0
170
+ changelog_uri: https://github.com/olympusone/spree_boxnow/releases/tag/v1.1.0
171
171
  documentation_uri: https://github.com/olympusone/spree_boxnow
172
172
  homepage_uri: https://github.com/olympusone/spree_boxnow
173
- source_code_uri: https://github.com/olympusone/spree_boxnow/tree/v1.0.0
173
+ source_code_uri: https://github.com/olympusone/spree_boxnow/tree/v1.1.0
174
174
  rdoc_options: []
175
175
  require_paths:
176
176
  - lib