spree_boxnow 1.0.0 → 1.2.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: f62d5fc8bf2894744140e90eba7aa7c6eeeda3b45f5d2002631196747ab663e7
4
+ data.tar.gz: 87466f19dc78f545ec26bc3e140f53c9759fecb9a57f811853362b91b4000b69
5
5
  SHA512:
6
- metadata.gz: a4326bb2f22ce5ad3864e72ba7ae3b616a3418ddf73f26252a1821b1adc531d38b7a8a61c1768f1f60ce5b848cee062814dd5465d07451821fe4b43aaef41d81
7
- data.tar.gz: edb9c3871bf6237ee19111de09a48d18d4fdc2a437ffd7140ee2a02f0f1adf01875dc7f36aacd49e6535e023b0593808982fd015b2855c87f9ae76548623f473
6
+ metadata.gz: 0d1b9a5368eef166ef932bb2b53e93930f57ddc911618efbd97acaec6154004fa7f870dedb86fa1a05b0fcb3e97521ab3858fd5fbcdb03ef1002d7d73d531e69
7
+ data.tar.gz: e641fb1f659b9c27d2275127f17144472e109c42762291aad553f49d34d0e04d9eda07be8cade5d1e126f066c4d161e1c1d8b372a6d16ef10b2d82b73f296f08
data/README.md CHANGED
@@ -34,16 +34,16 @@ A [Spree Commerce](https://spreecommerce.org) extension that integrates **BoxNow
34
34
 
35
35
  Go to **Admin → Integrations → BoxNow** and fill in:
36
36
 
37
- | Field | Description |
38
- |-------|-------------|
39
- | **Client ID** | Provided by BoxNow |
40
- | **Client Secret** | Provided by BoxNow |
41
- | **Partner ID** | Provided by BoxNow |
42
- | **API URL** | BoxNow API base URL (e.g. `https://api-production.boxnow.gr`) |
43
- | **Origin Location ID** | Your warehouse/store APM location ID |
44
- | **Contact Name** | Sender contact name printed on labels |
45
- | **Contact Phone** | Sender phone number |
46
- | **Contact Email** | Sender email address |
37
+ | Field | Description |
38
+ | ---------------------- | ------------------------------------------------------------- |
39
+ | **Client ID** | Provided by BoxNow |
40
+ | **Client Secret** | Provided by BoxNow |
41
+ | **Partner ID** | Provided by BoxNow |
42
+ | **API URL** | BoxNow API base URL (e.g. `https://api-production.boxnow.gr`) |
43
+ | **Origin Location ID** | Your warehouse/store APM location ID |
44
+ | **Contact Name** | Sender contact name printed on labels |
45
+ | **Contact Phone** | Sender phone number |
46
+ | **Contact Email** | Sender email address |
47
47
 
48
48
  OAuth2 tokens are obtained automatically using Client Credentials and cached in `Rails.cache` for 1 hour.
49
49
 
@@ -56,12 +56,12 @@ The shipping calculator determines the price tier from the physical dimensions o
56
56
 
57
57
  BoxNow hard limits:
58
58
 
59
- | Dimension | Limit |
60
- |-----------|-------|
59
+ | Dimension | Limit |
60
+ | ---------- | ----- |
61
61
  | Max weight | 20 kg |
62
62
  | Max height | 36 cm |
63
- | Max width | 45 cm |
64
- | Max depth | 60 cm |
63
+ | Max width | 45 cm |
64
+ | Max depth | 60 cm |
65
65
 
66
66
  ### 3. Create a BoxNow shipping method
67
67
 
@@ -72,13 +72,13 @@ Go to **Admin → Shipping Methods → New**:
72
72
  3. Select **BoxNow Rate** as the calculator
73
73
  4. Set the calculator preferences:
74
74
 
75
- | Preference | Default | Description |
76
- |------------|---------|-------------|
77
- | **Small box price** | 0.0 | Price for parcels ≤ 8 cm in height |
78
- | **Medium box price** | 0.0 | Price for parcels ≤ 17 cm in height |
79
- | **Large box price** | 0.0 | Price for parcels ≤ 36 cm in height |
80
- | **Base padding (cm)** | 1.0 | Added to every dimension to account for the physical box being slightly larger than its contents. Set to `0` to disable. |
81
- | **Multi-item factor** | 1.05 | Multiplier applied to all dimensions when an order has more than one item (accounts for imperfect stacking). Set to `1.0` to disable. |
75
+ | Preference | Default | Description |
76
+ | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- |
77
+ | **Small box price** | 0.0 | Price for parcels ≤ 8 cm in height |
78
+ | **Medium box price** | 0.0 | Price for parcels ≤ 17 cm in height |
79
+ | **Large box price** | 0.0 | Price for parcels ≤ 36 cm in height |
80
+ | **Base padding (cm)** | 1.0 | Added to every dimension to account for the physical box being slightly larger than its contents. Set to `0` to disable. |
81
+ | **Multi-item factor** | 1.05 | Multiplier applied to all dimensions when an order has more than one item (accounts for imperfect stacking). Set to `1.0` to disable. |
82
82
 
83
83
  ---
84
84
 
@@ -87,17 +87,18 @@ Go to **Admin → Shipping Methods → New**:
87
87
  The calculator models the entire order as **one parcel** — there is no multi-box splitting.
88
88
 
89
89
  For each line item it:
90
+
90
91
  1. Sorts the variant's three dimensions smallest → largest (`s ≤ m ≤ d`)
91
92
  2. Stacks items along the smallest axis: `parcel_height += s × quantity`
92
93
  3. Takes `width = max(m)` and `depth = max(d)` across all items
93
94
 
94
95
  After stacking, padding and the multi-item factor are applied. The resulting three dimensions are then sorted again to allow virtual rotation, and the smallest is compared against the tier thresholds.
95
96
 
96
- | Tier | Height threshold |
97
- |------|-----------------|
98
- | Small | ≤ 8 cm |
99
- | Medium | ≤ 17 cm |
100
- | Large | ≤ 36 cm |
97
+ | Tier | Height threshold |
98
+ | ------ | ---------------- |
99
+ | Small | ≤ 8 cm |
100
+ | Medium | ≤ 17 cm |
101
+ | Large | ≤ 36 cm |
101
102
 
102
103
  If the package exceeds the large threshold or any hard limit, `nil` is returned and the BoxNow shipping option is hidden from checkout.
103
104
 
@@ -159,12 +160,12 @@ If a voucher creation attempt fails and the admin retries, the extension appends
159
160
 
160
161
  ## Routes
161
162
 
162
- | Method | Path | Action |
163
- |--------|------|--------|
164
- | `POST` | `/boxnow/select_locker` | Storefront: save locker selection |
165
- | `POST` | `/{admin_path}/boxnow/:order_id/create` | Admin: create voucher |
166
- | `GET` | `/{admin_path}/boxnow/:order_id/print` | Admin: print/download voucher PDF |
167
- | `POST` | `/{admin_path}/boxnow/:order_id/cancel` | Admin: cancel voucher |
163
+ | Method | Path | Action |
164
+ | ------ | ---------------------------------------------- | ------------------------------------------------- |
165
+ | `POST` | `/boxnow/select_locker` | Storefront: save locker selection |
166
+ | `POST` | `/{admin_path}/boxnow/:order_id/create` | Admin: create voucher |
167
+ | `GET` | `/{admin_path}/boxnow/:order_id/print` | Admin: print/download voucher PDF |
168
+ | `POST` | `/{admin_path}/boxnow/:order_id/cancel` | Admin: cancel voucher |
168
169
  | `POST` | `/{admin_path}/boxnow/:order_id/select_locker` | Admin: update locker selection (pre-voucher only) |
169
170
 
170
171
  ---
@@ -0,0 +1,19 @@
1
+ require 'phonelib'
2
+
3
+ module SpreeBoxnow
4
+ # Normalizes phone numbers to the full international (E.164) format BoxNow
5
+ # requires, e.g. +306912345678. `country_iso` is the ISO 3166-1 alpha-2
6
+ # code used to interpret a number given in national format (defaults to
7
+ # GR, since BoxNow only operates in Greece); numbers already given in
8
+ # international format (leading +) are parsed as-is regardless.
9
+ module PhoneNormalizer
10
+ module_function
11
+
12
+ def call(phone, country_iso = nil)
13
+ return '' if phone.blank?
14
+
15
+ parsed = Phonelib.parse(phone, country_iso || 'GR')
16
+ parsed.valid? ? parsed.e164 : phone.to_s
17
+ end
18
+ end
19
+ end
@@ -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]
@@ -31,13 +31,13 @@ module SpreeBoxnow
31
31
  origin: {
32
32
  locationId: integration.preferred_origin_location_id,
33
33
  contactName: integration.preferred_contact_name.presence || '',
34
- contactNumber: integration.preferred_contact_phone.presence || '',
34
+ contactNumber: PhoneNormalizer.call(integration.preferred_contact_phone),
35
35
  contactEmail: integration.preferred_contact_email.presence || ''
36
36
  },
37
37
  destination: {
38
38
  locationId: destination_id,
39
39
  contactName: address.full_name,
40
- contactNumber: address.phone.to_s,
40
+ contactNumber: PhoneNormalizer.call(address.phone, address.country_iso),
41
41
  contactEmail: order.email.to_s
42
42
  },
43
43
  items: [{
@@ -1,5 +1,5 @@
1
1
  module SpreeBoxnow
2
- VERSION = '1.0.0'.freeze
2
+ VERSION = '1.2.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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OlympusOne
@@ -107,6 +107,20 @@ dependencies:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: '2.0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: phonelib
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.10'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.10'
110
124
  - !ruby/object:Gem::Dependency
111
125
  name: spree_dev_tools
112
126
  requirement: !ruby/object:Gem::Requirement
@@ -136,6 +150,7 @@ files:
136
150
  - app/javascript/spree_boxnow/application.js
137
151
  - app/javascript/spree_boxnow/controllers/spree_boxnow_controller.js
138
152
  - app/lib/spree_boxnow/api_client.rb
153
+ - app/lib/spree_boxnow/phone_normalizer.rb
139
154
  - app/models/spree/calculator/shipping/boxnow_rate.rb
140
155
  - app/models/spree/integrations/boxnow.rb
141
156
  - app/models/spree/order_decorator.rb
@@ -167,10 +182,10 @@ licenses:
167
182
  - MIT
168
183
  metadata:
169
184
  bug_tracker_uri: https://github.com/olympusone/spree_boxnow/issues
170
- changelog_uri: https://github.com/olympusone/spree_boxnow/releases/tag/v1.0.0
185
+ changelog_uri: https://github.com/olympusone/spree_boxnow/releases/tag/v1.2.0
171
186
  documentation_uri: https://github.com/olympusone/spree_boxnow
172
187
  homepage_uri: https://github.com/olympusone/spree_boxnow
173
- source_code_uri: https://github.com/olympusone/spree_boxnow/tree/v1.0.0
188
+ source_code_uri: https://github.com/olympusone/spree_boxnow/tree/v1.2.0
174
189
  rdoc_options: []
175
190
  require_paths:
176
191
  - lib