freight_kit 0.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 +7 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +201 -0
- data/MIT-LICENSE +31 -0
- data/README.md +153 -0
- data/VERSION +1 -0
- data/accessorial_symbols.txt +95 -0
- data/freight_kit.gemspec +58 -0
- data/lib/freight_kit/carrier.rb +473 -0
- data/lib/freight_kit/carriers.rb +24 -0
- data/lib/freight_kit/contact.rb +17 -0
- data/lib/freight_kit/error.rb +5 -0
- data/lib/freight_kit/errors/document_not_found_error.rb +5 -0
- data/lib/freight_kit/errors/expired_credentials_error.rb +5 -0
- data/lib/freight_kit/errors/http_error.rb +25 -0
- data/lib/freight_kit/errors/invalid_credentials_error.rb +5 -0
- data/lib/freight_kit/errors/response_error.rb +16 -0
- data/lib/freight_kit/errors/shipment_not_found_error.rb +5 -0
- data/lib/freight_kit/errors/unserviceable_accessorials_error.rb +17 -0
- data/lib/freight_kit/errors/unserviceable_error.rb +5 -0
- data/lib/freight_kit/errors.rb +10 -0
- data/lib/freight_kit/model.rb +17 -0
- data/lib/freight_kit/models/credential.rb +117 -0
- data/lib/freight_kit/models/date_time.rb +37 -0
- data/lib/freight_kit/models/document_response.rb +17 -0
- data/lib/freight_kit/models/label.rb +13 -0
- data/lib/freight_kit/models/location.rb +108 -0
- data/lib/freight_kit/models/pickup_response.rb +19 -0
- data/lib/freight_kit/models/price.rb +38 -0
- data/lib/freight_kit/models/rate.rb +81 -0
- data/lib/freight_kit/models/rate_response.rb +15 -0
- data/lib/freight_kit/models/response.rb +21 -0
- data/lib/freight_kit/models/shipment.rb +66 -0
- data/lib/freight_kit/models/shipment_event.rb +38 -0
- data/lib/freight_kit/models/tracking_response.rb +75 -0
- data/lib/freight_kit/models.rb +17 -0
- data/lib/freight_kit/package.rb +313 -0
- data/lib/freight_kit/package_item.rb +65 -0
- data/lib/freight_kit/packaging.rb +52 -0
- data/lib/freight_kit/platform.rb +36 -0
- data/lib/freight_kit/shipment_packer.rb +116 -0
- data/lib/freight_kit/tariff.rb +29 -0
- data/lib/freight_kit/version.rb +5 -0
- data/lib/freight_kit.rb +34 -0
- data/service_type_symbols.txt +4 -0
- data/shipment_event_symbols.txt +17 -0
- metadata +453 -0
@@ -0,0 +1,313 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FreightKit # :nodoc:
|
4
|
+
class Package
|
5
|
+
class << self
|
6
|
+
def cents_from(money)
|
7
|
+
return if money.nil?
|
8
|
+
|
9
|
+
if money.respond_to?(:cents)
|
10
|
+
money.cents
|
11
|
+
else
|
12
|
+
case money
|
13
|
+
when Float
|
14
|
+
(money * 100).round
|
15
|
+
when String
|
16
|
+
money =~ /\./ ? (money.to_f * 100).round : money.to_i
|
17
|
+
else
|
18
|
+
money.to_i
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
VALID_FREIGHT_CLASSES = [55, 60, 65, 70, 77.5, 85, 92.5, 100, 110, 125, 150, 175, 200, 250, 300, 400].freeze
|
25
|
+
|
26
|
+
cattr_accessor :default_options
|
27
|
+
attr_accessor :description, :hazmat, :nmfc, :quantity
|
28
|
+
attr_reader :currency, :options, :packaging, :value
|
29
|
+
attr_writer :declared_freight_class
|
30
|
+
|
31
|
+
# Package.new(100, [10, 20, 30], 'pallet', :units => :metric)
|
32
|
+
# Package.new(Measured::Weight.new(100, :g), 'box', [10, 20, 30].map {|m| Length.new(m, :centimetres)})
|
33
|
+
# Package.new(100.grams, [10, 20, 30].map(&:centimetres))
|
34
|
+
def initialize(total_grams_or_ounces, dimensions, packaging_type, options = {})
|
35
|
+
options = @@default_options.update(options) if @@default_options
|
36
|
+
options.symbolize_keys!
|
37
|
+
@options = options
|
38
|
+
|
39
|
+
raise ArgumentError, 'Package#new: packaging_type is required' unless packaging_type
|
40
|
+
raise ArgumentError, 'Package#new: quantity is required' unless options[:quantity]
|
41
|
+
|
42
|
+
# For backward compatibility
|
43
|
+
if dimensions.is_a?(Array)
|
44
|
+
@dimensions = [dimensions].flatten.reject(&:nil?)
|
45
|
+
else
|
46
|
+
@dimensions = [dimensions.dig(:height), dimensions.dig(:width), dimensions.dig(:length)]
|
47
|
+
@dimensions = [@dimensions].flatten.reject(&:nil?)
|
48
|
+
end
|
49
|
+
|
50
|
+
@description = options[:description]
|
51
|
+
@hazmat = options[:hazmat] == true
|
52
|
+
@nmfc = options[:nmfc].presence
|
53
|
+
|
54
|
+
imperial = (options[:units] == :imperial)
|
55
|
+
|
56
|
+
weight_imperial = dimensions_imperial = imperial if options.include?(:units)
|
57
|
+
|
58
|
+
weight_imperial = (options[:weight_units] == :imperial) if options.include?(:weight_units)
|
59
|
+
|
60
|
+
dimensions_imperial = (options[:dim_units] == :imperial) if options.include?(:dim_units)
|
61
|
+
|
62
|
+
@weight_unit_system = weight_imperial ? :imperial : :metric
|
63
|
+
@dimensions_unit_system = dimensions_imperial ? :imperial : :metric
|
64
|
+
|
65
|
+
@quantity = options[:quantity] || 1
|
66
|
+
|
67
|
+
@total_weight = attribute_from_metric_or_imperial(
|
68
|
+
total_grams_or_ounces,
|
69
|
+
Measured::Weight,
|
70
|
+
@weight_unit_system,
|
71
|
+
:grams,
|
72
|
+
:ounces,
|
73
|
+
)
|
74
|
+
|
75
|
+
@each_weight = attribute_from_metric_or_imperial(
|
76
|
+
total_grams_or_ounces / @quantity.to_f,
|
77
|
+
Measured::Weight,
|
78
|
+
@weight_unit_system,
|
79
|
+
:grams,
|
80
|
+
:ounces,
|
81
|
+
)
|
82
|
+
|
83
|
+
if @dimensions.blank?
|
84
|
+
zero_length = Measured::Length.new(0, (dimensions_imperial ? :inches : :centimetres))
|
85
|
+
@dimensions = [zero_length] * 3
|
86
|
+
else
|
87
|
+
# Overriding ReactiveShipping's protected process_dimensions which sorts
|
88
|
+
# them making it confusing for ReactiveFreight carrier API's that expect
|
89
|
+
# the H x W x L order. Since H x W x L is nonstandard in the freight
|
90
|
+
# industry ReactiveFreight introduces explicit functions for each
|
91
|
+
@dimensions = @dimensions.map do |l|
|
92
|
+
attribute_from_metric_or_imperial(l, Measured::Length, @dimensions_unit_system, :centimetres, :inches)
|
93
|
+
end
|
94
|
+
2.downto(@dimensions.length) do |_n|
|
95
|
+
@dimensions.unshift(@dimensions[0])
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
@value = Package.cents_from(options[:value])
|
100
|
+
@currency = options[:currency] || (options[:value].currency if options[:value].respond_to?(:currency))
|
101
|
+
@cylinder = options[:cylinder] || options[:tube] ? true : false
|
102
|
+
@gift = options[:gift] ? true : false
|
103
|
+
@oversized = options[:oversized] ? true : false
|
104
|
+
@unpackaged = options[:unpackaged] ? true : false
|
105
|
+
@packaging = Packaging.new(packaging_type)
|
106
|
+
end
|
107
|
+
|
108
|
+
def cubic_ft(each_or_total)
|
109
|
+
q = case each_or_total
|
110
|
+
when :each then 1
|
111
|
+
when :total then @quantity
|
112
|
+
else
|
113
|
+
raise ArgumentError, 'each_or_total must be one of :each, :total'
|
114
|
+
end
|
115
|
+
|
116
|
+
return unless inches[..2].all?(&:present?)
|
117
|
+
|
118
|
+
cubic_ft = (inches[0] * inches[1] * inches[2]).to_f / 1728
|
119
|
+
cubic_ft *= q
|
120
|
+
|
121
|
+
format('%0.2f', cubic_ft).to_f
|
122
|
+
end
|
123
|
+
|
124
|
+
def density
|
125
|
+
return unless inches[..2].all?(&:present?) && pounds(:each)
|
126
|
+
|
127
|
+
density = pounds(:each).to_f / cubic_ft(:each)
|
128
|
+
format('%0.2f', density).to_f
|
129
|
+
end
|
130
|
+
|
131
|
+
def calculated_freight_class
|
132
|
+
sanitized_freight_class(density_to_freight_class(density))
|
133
|
+
end
|
134
|
+
|
135
|
+
def declared_freight_class
|
136
|
+
@declared_freight_class || @options[:declared_freight_class]
|
137
|
+
end
|
138
|
+
|
139
|
+
def freight_class
|
140
|
+
(declared_freight_class.presence || calculated_freight_class)
|
141
|
+
end
|
142
|
+
|
143
|
+
def length(unit)
|
144
|
+
@dimensions[2].convert_to(unit).value.to_f
|
145
|
+
end
|
146
|
+
|
147
|
+
def width(unit)
|
148
|
+
@dimensions[1].convert_to(unit).value.to_f
|
149
|
+
end
|
150
|
+
|
151
|
+
def height(unit)
|
152
|
+
@dimensions[0].convert_to(unit).value.to_f
|
153
|
+
end
|
154
|
+
|
155
|
+
def cylinder?
|
156
|
+
@cylinder
|
157
|
+
end
|
158
|
+
|
159
|
+
def oversized?
|
160
|
+
@oversized
|
161
|
+
end
|
162
|
+
|
163
|
+
def unpackaged?
|
164
|
+
@unpackaged
|
165
|
+
end
|
166
|
+
|
167
|
+
alias_method :tube?, :cylinder?
|
168
|
+
|
169
|
+
def gift?
|
170
|
+
@gift
|
171
|
+
end
|
172
|
+
|
173
|
+
def hazmat?
|
174
|
+
@hazmat
|
175
|
+
end
|
176
|
+
|
177
|
+
def ounces(options = {})
|
178
|
+
weight(options).convert_to(:oz).value.to_f
|
179
|
+
end
|
180
|
+
alias_method :oz, :ounces
|
181
|
+
|
182
|
+
def grams(options = {})
|
183
|
+
weight(options).convert_to(:g).value.to_f
|
184
|
+
end
|
185
|
+
alias_method :g, :grams
|
186
|
+
|
187
|
+
def pounds(args)
|
188
|
+
weight(*args).convert_to(:lb).value.to_f
|
189
|
+
end
|
190
|
+
alias_method :lb, :pounds
|
191
|
+
alias_method :lbs, :pounds
|
192
|
+
|
193
|
+
def kilograms(options = {})
|
194
|
+
weight(options).convert_to(:kg).value.to_f
|
195
|
+
end
|
196
|
+
alias_method :kg, :kilograms
|
197
|
+
alias_method :kgs, :kilograms
|
198
|
+
|
199
|
+
def inches(measurement = nil)
|
200
|
+
@inches ||= @dimensions.map { |m| m.convert_to(:in).value.to_f }
|
201
|
+
measurement.nil? ? @inches : measure(measurement, @inches)
|
202
|
+
end
|
203
|
+
alias_method :in, :inches
|
204
|
+
|
205
|
+
def centimetres(measurement = nil)
|
206
|
+
@centimetres ||= @dimensions.map { |m| m.convert_to(:cm).value.to_f }
|
207
|
+
measurement.nil? ? @centimetres : measure(measurement, @centimetres)
|
208
|
+
end
|
209
|
+
alias_method :cm, :centimetres
|
210
|
+
|
211
|
+
def dim_weight
|
212
|
+
return if inches(:length).blank? || inches(:width).blank? || inches(:height).blank? || pounds(:each).blank?
|
213
|
+
|
214
|
+
@dim_weight ||= (inches(:length).ceil * inches(:width).ceil * inches(:height).ceil).to_f / 139
|
215
|
+
end
|
216
|
+
|
217
|
+
def each_weight(options = {})
|
218
|
+
weight(@each_weight, options)
|
219
|
+
end
|
220
|
+
|
221
|
+
def total_weight(options = {})
|
222
|
+
weight(@total_weight, options)
|
223
|
+
end
|
224
|
+
|
225
|
+
private
|
226
|
+
|
227
|
+
def attribute_from_metric_or_imperial(obj, klass, unit_system, metric_unit, imperial_unit)
|
228
|
+
if obj.is_a?(klass)
|
229
|
+
obj
|
230
|
+
else
|
231
|
+
klass.new(obj, (unit_system == :imperial ? imperial_unit : metric_unit))
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def density_to_freight_class(density)
|
236
|
+
return unless density
|
237
|
+
return 400 if density < 1
|
238
|
+
return 60 if density > 30
|
239
|
+
|
240
|
+
density_table = [
|
241
|
+
[1, 2, 300],
|
242
|
+
[2, 4, 250],
|
243
|
+
[4, 6, 175],
|
244
|
+
[6, 8, 125],
|
245
|
+
[8, 10, 100],
|
246
|
+
[10, 12, 92.5],
|
247
|
+
[12, 15, 85],
|
248
|
+
[15, 22.5, 70],
|
249
|
+
[22.5, 30, 65],
|
250
|
+
[30, 35, 60],
|
251
|
+
]
|
252
|
+
density_table.each do |density_row|
|
253
|
+
return density_row[2] if (density >= density_row[0]) && (density < density_row[1])
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
def sanitized_freight_class(freight_class)
|
258
|
+
return if freight_class.blank?
|
259
|
+
|
260
|
+
if VALID_FREIGHT_CLASSES.include?(freight_class)
|
261
|
+
return freight_class.to_i == freight_class ? freight_class.to_i : freight_class
|
262
|
+
end
|
263
|
+
|
264
|
+
nil
|
265
|
+
end
|
266
|
+
|
267
|
+
def measure(measurement, ary)
|
268
|
+
case measurement
|
269
|
+
when Integer then ary[measurement]
|
270
|
+
when :x, :max, :length, :long then ary[2]
|
271
|
+
when :y, :mid, :width, :wide then ary[1]
|
272
|
+
when :z, :min, :height, :depth, :high, :deep then ary[0]
|
273
|
+
when :girth, :around, :circumference
|
274
|
+
cylinder? ? (Math::PI * (ary[0] + ary[1]) / 2) : (2 * ary[0]) + (2 * ary[1])
|
275
|
+
when :volume then cylinder? ? (Math::PI * (ary[0] + ary[1]) / 4)**2 * ary[2] : measure(:box_volume, ary)
|
276
|
+
when :box_volume then ary[0] * ary[1] * ary[2]
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
def process_dimensions
|
281
|
+
@dimensions = @dimensions.map do |l|
|
282
|
+
attribute_from_metric_or_imperial(l, Measured::Length, @dimensions_unit_system, :centimetres, :inches)
|
283
|
+
end.sort
|
284
|
+
# [1,2] => [1,1,2]
|
285
|
+
# [5] => [5,5,5]
|
286
|
+
# etc..
|
287
|
+
2.downto(@dimensions.length) do |_n|
|
288
|
+
@dimensions.unshift(@dimensions[0])
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
def weight(which_weight, options = {})
|
293
|
+
weight = case which_weight
|
294
|
+
when :each then @each_weight
|
295
|
+
when :total then @total_weight
|
296
|
+
else
|
297
|
+
raise ArgumentError, 'which_weight must be one of :each, :total'
|
298
|
+
end
|
299
|
+
|
300
|
+
case options[:type]
|
301
|
+
when nil, :actual
|
302
|
+
weight
|
303
|
+
when :volumetric, :dimensional
|
304
|
+
@volumetric_weight ||= begin
|
305
|
+
m = Measured::Weight.new((centimetres(:box_volume) / 6.0), :grams)
|
306
|
+
@weight_unit_system == :imperial ? m.convert_to(:oz) : m
|
307
|
+
end
|
308
|
+
when :billable
|
309
|
+
[weight, weight(weight, type: :volumetric)].max
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FreightKit # :nodoc:
|
4
|
+
class PackageItem
|
5
|
+
attr_reader :sku, :hs_code, :value, :name, :quantity, :options
|
6
|
+
|
7
|
+
def initialize(name, grams_or_ounces, value, quantity, options = {})
|
8
|
+
@name = name
|
9
|
+
|
10
|
+
imperial = (options[:units] == :imperial)
|
11
|
+
|
12
|
+
@unit_system = imperial ? :imperial : :metric
|
13
|
+
|
14
|
+
@weight = grams_or_ounces
|
15
|
+
@weight = Measured::Weight.new(
|
16
|
+
grams_or_ounces,
|
17
|
+
(@unit_system == :imperial ? :oz : :g),
|
18
|
+
) unless @weight.is_a?(Measured::Weight)
|
19
|
+
|
20
|
+
@value = Package.cents_from(value)
|
21
|
+
@quantity = quantity > 0 ? quantity : 1
|
22
|
+
|
23
|
+
@sku = options[:sku]
|
24
|
+
@hs_code = options[:hs_code]
|
25
|
+
@options = options
|
26
|
+
end
|
27
|
+
|
28
|
+
def weight(options = {})
|
29
|
+
case options[:type]
|
30
|
+
when nil, :actual
|
31
|
+
@weight
|
32
|
+
when :volumetric, :dimensional
|
33
|
+
@volumetric_weight ||= begin
|
34
|
+
m = Measured::Weight.new((centimetres(:box_volume) / 6.0), :grams)
|
35
|
+
@unit_system == :imperial ? m.in_ounces : m
|
36
|
+
end
|
37
|
+
when :billable
|
38
|
+
[weight, weight(type: :volumetric)].max
|
39
|
+
end
|
40
|
+
end
|
41
|
+
alias_method :mass, :weight
|
42
|
+
|
43
|
+
def ounces(options = {})
|
44
|
+
weight(options).convert_to(:oz).value
|
45
|
+
end
|
46
|
+
alias_method :oz, :ounces
|
47
|
+
|
48
|
+
def grams(options = {})
|
49
|
+
weight(options).convert_to(:g).value
|
50
|
+
end
|
51
|
+
alias_method :g, :grams
|
52
|
+
|
53
|
+
def pounds(options = {})
|
54
|
+
weight(options).convert_to(:lb).value
|
55
|
+
end
|
56
|
+
alias_method :lb, :pounds
|
57
|
+
alias_method :lbs, :pounds
|
58
|
+
|
59
|
+
def kilograms(options = {})
|
60
|
+
weight(options).convert_to(:kg).value
|
61
|
+
end
|
62
|
+
alias_method :kg, :kilograms
|
63
|
+
alias_method :kgs, :kilograms
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FreightKit
|
4
|
+
class Packaging
|
5
|
+
VALID_TYPES = %i[
|
6
|
+
box
|
7
|
+
bundle
|
8
|
+
container
|
9
|
+
crate
|
10
|
+
cylinder
|
11
|
+
drum
|
12
|
+
luggage
|
13
|
+
pail
|
14
|
+
pallet
|
15
|
+
piece
|
16
|
+
roll
|
17
|
+
tote
|
18
|
+
truckload
|
19
|
+
tote
|
20
|
+
].freeze
|
21
|
+
|
22
|
+
PALLET_TYPES = %i[crate drum pallet tote].freeze
|
23
|
+
|
24
|
+
attr_accessor :type
|
25
|
+
|
26
|
+
# Packaging.new(:pallet)
|
27
|
+
def initialize(type, options = {})
|
28
|
+
options.symbolize_keys!
|
29
|
+
@options = options
|
30
|
+
|
31
|
+
unless VALID_TYPES.include?(type)
|
32
|
+
raise ArgumentError, "Package#new: `type` should be one of #{VALID_TYPES.join(", ")}"
|
33
|
+
end
|
34
|
+
|
35
|
+
@type = type
|
36
|
+
end
|
37
|
+
|
38
|
+
def box?
|
39
|
+
@box ||= BOX_TYPES.include?(@type)
|
40
|
+
end
|
41
|
+
|
42
|
+
def pallet?
|
43
|
+
@pallet ||= PALLET_TYPES.include?(@type)
|
44
|
+
end
|
45
|
+
|
46
|
+
def box_or_pallet_type
|
47
|
+
return :pallet if pallet?
|
48
|
+
|
49
|
+
box? ? :box : nil
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FreightKit
|
4
|
+
class Platform < Carrier
|
5
|
+
# Credentials should be a `Credential` or `Array` of `Credential`
|
6
|
+
def initialize(credentials, customer_location: nil, tariff: nil)
|
7
|
+
super
|
8
|
+
|
9
|
+
# Use #superclass instead of using #ancestors to fetch the parent class which the carrier class is inheriting from
|
10
|
+
# (#ancestors returns an array including the parent class and all the modules that were included)
|
11
|
+
parent_class_name = self.class.superclass.name.demodulize.underscore
|
12
|
+
|
13
|
+
conf_path = File
|
14
|
+
.join(
|
15
|
+
File.expand_path(
|
16
|
+
'../../../../configuration/platforms',
|
17
|
+
self.class.const_source_location(:REACTIVE_FREIGHT_PLATFORM).first,
|
18
|
+
),
|
19
|
+
"#{parent_class_name}.yml",
|
20
|
+
)
|
21
|
+
@conf = YAML.safe_load(File.read(conf_path), permitted_classes: [Symbol])
|
22
|
+
|
23
|
+
conf_path = File
|
24
|
+
.join(
|
25
|
+
File.expand_path(
|
26
|
+
'../../../../configuration/carriers',
|
27
|
+
self.class.const_source_location(:REACTIVE_FREIGHT_CARRIER).first,
|
28
|
+
),
|
29
|
+
"#{self.class.to_s.demodulize.underscore}.yml",
|
30
|
+
)
|
31
|
+
@conf = @conf.deep_merge(YAML.safe_load(File.read(conf_path), permitted_classes: [Symbol]))
|
32
|
+
|
33
|
+
@rates_with_excessive_length_fees = @conf.dig(:attributes, :rates, :with_excessive_length_fees)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FreightKit
|
4
|
+
class ShipmentPacker
|
5
|
+
class OverweightItem < StandardError
|
6
|
+
end
|
7
|
+
|
8
|
+
EXCESS_PACKAGE_QUANTITY_THRESHOLD = 10_000
|
9
|
+
class ExcessPackageQuantity < StandardError; end
|
10
|
+
|
11
|
+
# items - array of hashes containing quantity, grams and price.
|
12
|
+
# ex. `[{:quantity => 2, :price => 1.0, :grams => 50}]`
|
13
|
+
# dimensions - `[5.0, 15.0, 30.0]`
|
14
|
+
# maximum_weight - maximum weight in grams
|
15
|
+
# currency - ISO currency code
|
16
|
+
|
17
|
+
class << self
|
18
|
+
def pack(items, dimensions, maximum_weight, currency)
|
19
|
+
return [] if items.empty?
|
20
|
+
|
21
|
+
packages = []
|
22
|
+
items.map!(&:symbolize_keys)
|
23
|
+
|
24
|
+
# Naive in that it assumes weight is equally distributed across all items
|
25
|
+
# Should raise early enough in most cases
|
26
|
+
validate_total_weight(items, maximum_weight)
|
27
|
+
items_to_pack = items.map(&:dup).sort_by! { |i| i[:grams].to_i }
|
28
|
+
|
29
|
+
state = :package_empty
|
30
|
+
while state != :packing_finished
|
31
|
+
case state
|
32
|
+
when :package_empty
|
33
|
+
package_weight = 0
|
34
|
+
package_value = 0
|
35
|
+
state = :filling_package
|
36
|
+
when :filling_package
|
37
|
+
validate_package_quantity(packages.count)
|
38
|
+
|
39
|
+
items_to_pack.each do |item|
|
40
|
+
quantity = determine_fillable_quantity_for_package(item, maximum_weight, package_weight)
|
41
|
+
package_weight += item_weight(quantity, item[:grams])
|
42
|
+
package_value += item_value(quantity, item[:price])
|
43
|
+
item[:quantity] = item[:quantity].to_i - quantity
|
44
|
+
end
|
45
|
+
|
46
|
+
items_to_pack.reject! { |i| i[:quantity].to_i == 0 }
|
47
|
+
state = :package_full
|
48
|
+
when :package_full
|
49
|
+
packages << FreightKit::Package.new(package_weight, dimensions, value: package_value, currency: currency)
|
50
|
+
state = items_to_pack.any? ? :package_empty : :packing_finished
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
packages
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def validate_total_weight(items, maximum_weight)
|
60
|
+
total_weight = 0
|
61
|
+
items.each do |item|
|
62
|
+
total_weight += item[:quantity].to_i * item[:grams].to_i
|
63
|
+
|
64
|
+
if overweight_item?(item[:grams], maximum_weight)
|
65
|
+
message = <<~MESSAGE.squish
|
66
|
+
The item with weight of #{item[:grams]}g is heavier than the allowable package weight of
|
67
|
+
#{maximum_weight}g
|
68
|
+
MESSAGE
|
69
|
+
raise OverweightItem, message
|
70
|
+
end
|
71
|
+
|
72
|
+
raise_excess_quantity_error if maybe_excess_package_quantity?(total_weight, maximum_weight)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def validate_package_quantity(number_of_packages)
|
77
|
+
raise_excess_quantity_error if number_of_packages >= EXCESS_PACKAGE_QUANTITY_THRESHOLD
|
78
|
+
end
|
79
|
+
|
80
|
+
def raise_excess_quantity_error
|
81
|
+
raise ExcessPackageQuantity, "Unable to pack more than #{EXCESS_PACKAGE_QUANTITY_THRESHOLD} packages"
|
82
|
+
end
|
83
|
+
|
84
|
+
def overweight_item?(grams, maximum_weight)
|
85
|
+
grams.to_i > maximum_weight
|
86
|
+
end
|
87
|
+
|
88
|
+
def maybe_excess_package_quantity?(total_weight, maximum_weight)
|
89
|
+
total_weight > (maximum_weight * EXCESS_PACKAGE_QUANTITY_THRESHOLD)
|
90
|
+
end
|
91
|
+
|
92
|
+
def determine_fillable_quantity_for_package(item, maximum_weight, package_weight)
|
93
|
+
item_grams = item[:grams].to_i
|
94
|
+
item_quantity = item[:quantity].to_i
|
95
|
+
|
96
|
+
if item_grams <= 0
|
97
|
+
item_quantity
|
98
|
+
else
|
99
|
+
# Grab the max amount of this item we can fit into this package
|
100
|
+
# Or, if there are fewer than the max for this item, put
|
101
|
+
# what is left into this package
|
102
|
+
available_grams = (maximum_weight - package_weight).to_i
|
103
|
+
[available_grams / item_grams, item_quantity].min
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def item_weight(quantity, grams)
|
108
|
+
quantity * grams.to_i
|
109
|
+
end
|
110
|
+
|
111
|
+
def item_value(quantity, price)
|
112
|
+
quantity * Package.cents_from(price)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FreightKit
|
4
|
+
class Tariff
|
5
|
+
attr_accessor :overlength_rules
|
6
|
+
|
7
|
+
def initialize(options = {})
|
8
|
+
options.symbolize_keys!
|
9
|
+
@options = options
|
10
|
+
|
11
|
+
@options[:overlength_rules] = (@options[:overlength_rules].presence || [])
|
12
|
+
raise ArgumentError, 'overlength_rules must be an Array' unless @options[:overlength_rules].is_a?(Array)
|
13
|
+
|
14
|
+
@options[:overlength_rules].each do |overlength_rule|
|
15
|
+
if !overlength_rule[:min_length].is_a?(Measured::Length)
|
16
|
+
raise ArgumentError, 'overlength_rule[:min_length] must be a Measured::Length'
|
17
|
+
elsif ![Measured::Length, NilClass].include?(overlength_rule[:max_length].class)
|
18
|
+
raise ArgumentError, 'overlength_rule[:max_length] must be one of Measured::Length, NilClass'
|
19
|
+
end
|
20
|
+
|
21
|
+
unless overlength_rule[:fee_cents].is_a?(Integer)
|
22
|
+
raise ArgumentError, 'overlength_rule[:fee_cents] must be an Integer'
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
@overlength_rules = @options[:overlength_rules]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/freight_kit.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_model'
|
4
|
+
require 'active_support/all'
|
5
|
+
require 'active_utils'
|
6
|
+
|
7
|
+
require 'cgi'
|
8
|
+
require 'yaml'
|
9
|
+
|
10
|
+
require 'httparty'
|
11
|
+
require 'measured'
|
12
|
+
require 'mimemagic'
|
13
|
+
require 'nokogiri'
|
14
|
+
require 'open-uri'
|
15
|
+
require 'place_kit'
|
16
|
+
require 'savon'
|
17
|
+
require 'watir'
|
18
|
+
|
19
|
+
require 'freight_kit/error'
|
20
|
+
require 'freight_kit/errors'
|
21
|
+
|
22
|
+
require 'freight_kit/model'
|
23
|
+
require 'freight_kit/models'
|
24
|
+
|
25
|
+
require 'freight_kit/carrier'
|
26
|
+
require 'freight_kit/carriers'
|
27
|
+
require 'freight_kit/contact'
|
28
|
+
require 'freight_kit/package_item'
|
29
|
+
require 'freight_kit/package'
|
30
|
+
require 'freight_kit/packaging'
|
31
|
+
require 'freight_kit/platform'
|
32
|
+
require 'freight_kit/shipment_packer'
|
33
|
+
require 'freight_kit/tariff'
|
34
|
+
require 'freight_kit/version'
|
@@ -0,0 +1,17 @@
|
|
1
|
+
:arrived_at_terminal
|
2
|
+
:delayed_due_to_weather
|
3
|
+
:delivered
|
4
|
+
:delivery_appointment_scheduled
|
5
|
+
:departed
|
6
|
+
:found
|
7
|
+
:located
|
8
|
+
:lost
|
9
|
+
:out_for_delivery
|
10
|
+
:pending_delivery_appointment
|
11
|
+
:picked_up
|
12
|
+
:pickup_driver_assigned
|
13
|
+
:pickup_information_received_by_carrier
|
14
|
+
:pickup_information_sent_to_carrier
|
15
|
+
:sailed
|
16
|
+
:trailer_closed
|
17
|
+
:trailer_unloaded
|