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 +4 -4
- data/app/models/spree/calculator/shipping/boxnow_rate.rb +152 -37
- data/lib/spree_boxnow/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfab3f930d7ab48d352e99628fc3c0c4eb570b9ff11c2b93dc4dbb563dba4448
|
|
4
|
+
data.tar.gz: 0b31a62d516f41268f2115605e8c2f295a08a57c41eca025efff102d5558d1d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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,
|
|
22
|
-
preference :multi_item_factor,
|
|
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
|
-
|
|
39
|
-
return nil if
|
|
40
|
+
boxes = minimum_bounding_boxes(package)
|
|
41
|
+
return nil if boxes.nil?
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
best_size = nil
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
56
|
+
return nil unless best_size
|
|
57
|
+
|
|
58
|
+
price_for(best_size)
|
|
51
59
|
end
|
|
52
60
|
|
|
53
61
|
private
|
|
54
62
|
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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]
|
data/lib/spree_boxnow/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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
|