rate-card 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/LICENSE.txt +21 -0
- data/README.md +226 -0
- data/exe/rate-card +101 -0
- data/lib/rate_card/client.rb +124 -0
- data/lib/rate_card/constants/addresses.rb +101 -0
- data/lib/rate_card/constants/carriers.rb +49 -0
- data/lib/rate_card/constants/cubic_tiers.rb +67 -0
- data/lib/rate_card/csv_writer.rb +58 -0
- data/lib/rate_card/failure.rb +8 -0
- data/lib/rate_card/grid.rb +184 -0
- data/lib/rate_card/input.rb +32 -0
- data/lib/rate_card/run_spec.rb +164 -0
- data/lib/rate_card/runner.rb +43 -0
- data/lib/rate_card/service.rb +38 -0
- data/lib/rate_card/service_catalog.rb +62 -0
- data/lib/rate_card/shipment.rb +78 -0
- data/lib/rate_card/table_renderer.rb +78 -0
- data/lib/rate_card/token.rb +48 -0
- data/lib/rate_card/token_prompt.rb +58 -0
- data/lib/rate_card/tui/app.rb +521 -0
- data/lib/rate_card/tui/fields/multi_select.rb +120 -0
- data/lib/rate_card/tui/fields/select.rb +80 -0
- data/lib/rate_card/tui/fields/text.rb +73 -0
- data/lib/rate_card/tui/messages.rb +59 -0
- data/lib/rate_card/tui/theme.rb +51 -0
- data/lib/rate_card/ui.rb +126 -0
- data/lib/rate_card/version.rb +5 -0
- data/lib/rate_card/warning.rb +31 -0
- data/lib/rate_card.rb +51 -0
- metadata +189 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
module Constants
|
|
5
|
+
# The ten official USPS cubic-pricing tiers (Ground Advantage Cubic,
|
|
6
|
+
# Priority Mail Cubic): the tier a package prices under is decided by
|
|
7
|
+
# which bounding cube its dimensions fit inside, not by its weight — so
|
|
8
|
+
# each tier pairs a fixed cube with the weight cap USPS prices it under.
|
|
9
|
+
# Ported from ../../rate_table_builder/constants.rb's CUBIC_PARAMS.
|
|
10
|
+
module CubicTiers
|
|
11
|
+
TIERS = [
|
|
12
|
+
{ tier: 1, length: 3.0, width: 3.0, height: 3.0, weight_oz: 128 },
|
|
13
|
+
{ tier: 2, length: 6.0, width: 6.0, height: 6.0, weight_oz: 128 },
|
|
14
|
+
{ tier: 3, length: 7.5, width: 7.5, height: 7.5, weight_oz: 128 },
|
|
15
|
+
{ tier: 4, length: 8.5, width: 8.5, height: 8.5, weight_oz: 128 },
|
|
16
|
+
{ tier: 5, length: 9.0, width: 9.0, height: 9.0, weight_oz: 128 },
|
|
17
|
+
{ tier: 6, length: 10.0, width: 10.0, height: 10.0, weight_oz: 128 },
|
|
18
|
+
{ tier: 7, length: 10.5, width: 10.5, height: 10.5, weight_oz: 128 },
|
|
19
|
+
{ tier: 8, length: 11.0, width: 11.0, height: 11.0, weight_oz: 128 },
|
|
20
|
+
{ tier: 9, length: 11.25, width: 11.25, height: 11.25, weight_oz: 240 },
|
|
21
|
+
{ tier: 10, length: 11.75, width: 11.75, height: 11.75, weight_oz: 240 }
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def ids
|
|
27
|
+
TIERS.map { |t| t[:tier] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def find(tier_id)
|
|
31
|
+
TIERS.find { |t| t[:tier] == tier_id }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dims(tier_id)
|
|
35
|
+
tier = find(tier_id)
|
|
36
|
+
return nil unless tier
|
|
37
|
+
|
|
38
|
+
{ length: tier[:length], width: tier[:width], height: tier[:height] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def weight_oz(tier_id)
|
|
42
|
+
find(tier_id)&.fetch(:weight_oz)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def label(tier_id)
|
|
46
|
+
"Tier #{tier_id}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# One display choice per tier, for the wizard's multi-select, e.g.
|
|
50
|
+
# ["Tier 1 — 3x3x3in, ≤8lb", 1].
|
|
51
|
+
def choices
|
|
52
|
+
TIERS.map { |t| [choice_label(t), t[:tier]] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def choice_label(tier)
|
|
56
|
+
dims_label = "#{fmt(tier[:length])}x#{fmt(tier[:width])}x#{fmt(tier[:height])}in"
|
|
57
|
+
"#{label(tier[:tier])} — #{dims_label}, ≤#{tier[:weight_oz] / 16}lb"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Trims a whole-number float's trailing ".0" so tier labels read "3x3x3in"
|
|
61
|
+
# rather than "3.0x3.0x3.0in".
|
|
62
|
+
def fmt(number)
|
|
63
|
+
number == number.to_i ? number.to_i.to_s : number.to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
|
|
7
|
+
module RateCard
|
|
8
|
+
# Writes the grid to disk: one CSV per service per rate key, into the run's
|
|
9
|
+
# timestamped directory. A nil cell is an empty field, never 0.0.
|
|
10
|
+
class CsvWriter
|
|
11
|
+
# Called before fetching, so a bad destination is reported up front rather
|
|
12
|
+
# than after a few hundred calls and a minute of waiting have been spent.
|
|
13
|
+
def self.ensure_writable!(base)
|
|
14
|
+
base = Pathname.new(base)
|
|
15
|
+
FileUtils.mkdir_p(base)
|
|
16
|
+
raise OutputNotWritable, "cannot write to #{base}" unless base.writable?
|
|
17
|
+
rescue SystemCallError => e
|
|
18
|
+
raise OutputNotWritable, "cannot create #{base}: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(grid:, spec:)
|
|
22
|
+
@grid = grid
|
|
23
|
+
@spec = spec
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns Array<Pathname> of the files written.
|
|
27
|
+
def write
|
|
28
|
+
FileUtils.mkdir_p(spec.run_dir)
|
|
29
|
+
|
|
30
|
+
spec.services.flat_map do |service|
|
|
31
|
+
spec.rate_keys.map { |rate_key| write_file(service, rate_key) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :grid, :spec
|
|
38
|
+
|
|
39
|
+
def write_file(service, rate_key)
|
|
40
|
+
path = spec.run_dir.join("#{service.file_slug}_#{rate_key}.csv")
|
|
41
|
+
|
|
42
|
+
CSV.open(path, 'w') do |csv|
|
|
43
|
+
csv << [spec.row_header, *spec.zones]
|
|
44
|
+
spec.rows.each { |row| csv << row_for(service, rate_key, row) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
path
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def row_for(service, rate_key, row)
|
|
51
|
+
cells = spec.zones.map do |zone|
|
|
52
|
+
grid.value(service_id: service.id, rate_key: rate_key, weight: row, zone: zone)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
[spec.row_label(row), *cells]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
# One rate call that did not succeed. Keyed by (weight, zone) rather than by
|
|
5
|
+
# service, because a single call covers every selected service — when it
|
|
6
|
+
# fails, they all lose the same cell.
|
|
7
|
+
Failure = Struct.new(:weight, :zone, :message, keyword_init: true)
|
|
8
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parallel'
|
|
4
|
+
|
|
5
|
+
module RateCard
|
|
6
|
+
# The fetch engine and the assembled result.
|
|
7
|
+
#
|
|
8
|
+
# One call per (weight, zone); each response is harvested for every selected
|
|
9
|
+
# service, since eHub returns rates for all enabled services at once. A cell
|
|
10
|
+
# whose call failed is nil and is listed in #failures — never 0.0, which in a
|
|
11
|
+
# rate table reads as a real free rate.
|
|
12
|
+
class Grid
|
|
13
|
+
THREADS = 8
|
|
14
|
+
|
|
15
|
+
# eHub answers these as an HTTP 201 "success" with the per-service errors
|
|
16
|
+
# field describing a hiccup that clears on its own, so Client's status-code
|
|
17
|
+
# retry never sees them. Left alone, the exact same request prices a
|
|
18
|
+
# different random set of cells on every run.
|
|
19
|
+
TRANSIENT_ERROR_PATTERN = /too many requests|please try again|slow down/i
|
|
20
|
+
RETRY_BACKOFF = [0.5, 1.0].freeze
|
|
21
|
+
|
|
22
|
+
# on_progress: called with no arguments after each completed call.
|
|
23
|
+
# retry_sleeper: injected so retry backoff is testable without waiting.
|
|
24
|
+
def self.build(spec:, client:, on_progress: nil, retry_sleeper: ->(seconds) { sleep(seconds) })
|
|
25
|
+
new(spec).tap { |grid| grid.send(:fetch_all, client, on_progress, retry_sleeper) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :spec
|
|
29
|
+
|
|
30
|
+
def initialize(spec)
|
|
31
|
+
@spec = spec
|
|
32
|
+
@cells = {}
|
|
33
|
+
@failures = []
|
|
34
|
+
@warnings = Hash.new(0)
|
|
35
|
+
@succeeded = 0
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def value(service_id:, rate_key:, weight:, zone:)
|
|
40
|
+
@cells[[service_id, rate_key, weight, zone]]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def failures
|
|
44
|
+
@failures.sort_by { |f| [f.weight, f.zone] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# What the API said went wrong on calls that otherwise succeeded: the
|
|
48
|
+
# response-level `warnings` array and the per-service `errors` field. Both
|
|
49
|
+
# arrive with an HTTP 201, so without this a carrier outage would show up as
|
|
50
|
+
# a card of blank cells with nothing to explain them.
|
|
51
|
+
#
|
|
52
|
+
# This card's own services sort first, however loud the account-wide noise
|
|
53
|
+
# is: one call rates every service on the token, so a token with 37 services
|
|
54
|
+
# enabled can bury the two warnings that explain this card's blank cells
|
|
55
|
+
# under thirty about services nobody selected.
|
|
56
|
+
def warnings
|
|
57
|
+
@warnings.map { |(message, scope), count| Warning.new(message: message, count: count, scope: scope) }
|
|
58
|
+
.sort_by { |warning| [scope_rank(warning), -warning.count, warning.message] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# True only when no call got through. Deliberately NOT "no cell has a
|
|
62
|
+
# value": a run can have every call succeed and still price nothing, if the
|
|
63
|
+
# API does not return the selected service (USPS First Class is not priced
|
|
64
|
+
# above 13 oz, for instance). Conflating the two would blame the network for
|
|
65
|
+
# what is really a service or weight selection problem.
|
|
66
|
+
def all_failed?
|
|
67
|
+
@succeeded.zero? && @failures.any?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Did any cell actually get a rate? False means we have nothing to write.
|
|
71
|
+
def any_rates?
|
|
72
|
+
@cells.values.any?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def fetch_all(client, on_progress, retry_sleeper)
|
|
78
|
+
Parallel.each(cell_coordinates, in_threads: THREADS) do |weight, zone|
|
|
79
|
+
fetch_cell(client, weight, zone, retry_sleeper)
|
|
80
|
+
@mutex.synchronize { on_progress&.call }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cell_coordinates
|
|
85
|
+
spec.rows.product(spec.zones)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fetch_cell(client, weight, zone, retry_sleeper)
|
|
89
|
+
payload = Shipment.new(spec: spec, weight: weight, address: spec.address_for(zone)).payload
|
|
90
|
+
body = fetch_with_transient_retry(client, payload, retry_sleeper)
|
|
91
|
+
record_response(body, weight, zone)
|
|
92
|
+
@mutex.synchronize { @succeeded += 1 }
|
|
93
|
+
rescue Unauthorized
|
|
94
|
+
# No later call can succeed; let it abort the whole run.
|
|
95
|
+
raise
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
@failures << Failure.new(weight: weight, zone: zone, message: e.message)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def fetch_with_transient_retry(client, payload, retry_sleeper)
|
|
103
|
+
attempt = 0
|
|
104
|
+
loop do
|
|
105
|
+
body = client.fetch_rates(payload)
|
|
106
|
+
return body unless transient_error?(body) && attempt < RETRY_BACKOFF.length
|
|
107
|
+
|
|
108
|
+
retry_sleeper.call(RETRY_BACKOFF[attempt])
|
|
109
|
+
attempt += 1
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# True when a selected service's errors field reads as a transient hiccup
|
|
114
|
+
# rather than a real problem with this request.
|
|
115
|
+
def transient_error?(body)
|
|
116
|
+
by_id = index_by_service_id(body)
|
|
117
|
+
spec.services.any? { |service| error_detail(by_id[service.id]) =~ TRANSIENT_ERROR_PATTERN }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def record_response(body, weight, zone)
|
|
121
|
+
by_id = index_by_service_id(body)
|
|
122
|
+
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
record_warnings(body, by_id)
|
|
125
|
+
|
|
126
|
+
spec.services.each do |service|
|
|
127
|
+
entry = by_id[service.id]
|
|
128
|
+
spec.rate_keys.each do |rate_key|
|
|
129
|
+
field = RunSpec::RATE_KEY_FIELDS.fetch(rate_key)
|
|
130
|
+
@cells[[service.id, rate_key, weight, zone]] = coerce(entry && entry[field])
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Caller holds @mutex.
|
|
137
|
+
def record_warnings(body, by_id)
|
|
138
|
+
response_warnings(body).each { |message| @warnings[[message, Warning::ACCOUNT]] += 1 }
|
|
139
|
+
|
|
140
|
+
spec.services.each do |service|
|
|
141
|
+
detail = error_detail(by_id[service.id])
|
|
142
|
+
next if detail.nil?
|
|
143
|
+
|
|
144
|
+
@warnings[["#{service.name} (#{service.id}): #{detail}", Warning::SERVICE]] += 1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def scope_rank(warning)
|
|
149
|
+
warning.account_wide? ? 1 : 0
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def response_warnings(body)
|
|
153
|
+
return [] unless body.is_a?(Hash)
|
|
154
|
+
|
|
155
|
+
Array(body['warnings']).map(&:to_s).reject { |message| message.strip.empty? }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# `errors` is documented as populated when a service could not be rated. It
|
|
159
|
+
# comes back as a string, but an array is accepted so a list of carrier
|
|
160
|
+
# messages reads as one line instead of raising.
|
|
161
|
+
def error_detail(entry)
|
|
162
|
+
raw = entry && entry['errors']
|
|
163
|
+
detail = Array(raw).map(&:to_s).reject { |message| message.strip.empty? }.join('; ')
|
|
164
|
+
detail.empty? ? nil : detail
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def index_by_service_id(body)
|
|
168
|
+
entries = body.is_a?(Hash) ? (body['service_rates'] || []) : []
|
|
169
|
+
entries.each_with_object({}) do |entry, acc|
|
|
170
|
+
id = entry['service_id']
|
|
171
|
+
acc[id.to_i] = entry unless id.nil?
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# nil stays nil. Anything numeric becomes a Float. Never defaults to zero.
|
|
176
|
+
def coerce(raw)
|
|
177
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
178
|
+
|
|
179
|
+
Float(raw)
|
|
180
|
+
rescue ArgumentError, TypeError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
# Parsing and defaults for the free-text answers. Extracted from Wizard when
|
|
5
|
+
# the prompt flow moved to TUI::App: the parsing is not tied to how the
|
|
6
|
+
# question is asked, and keeping it separate is what lets it be tested
|
|
7
|
+
# without a terminal.
|
|
8
|
+
module Input
|
|
9
|
+
# Fallback only. Valid package types are a property of the service
|
|
10
|
+
# (services[].package_types[].type), so the catalogue is preferred; this
|
|
11
|
+
# covers a catalogue that reports none.
|
|
12
|
+
PACKAGE_TYPES = %w[parcel flat_rate_envelope flat_rate_box soft_pack].freeze
|
|
13
|
+
DEFAULT_WEIGHT_RANGE = '1-16'
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Parses "1-8", "1,3,5" or a mix into a sorted unique Array<Integer>.
|
|
18
|
+
def parse_range(input)
|
|
19
|
+
input.to_s.split(',').flat_map do |part|
|
|
20
|
+
part = part.strip
|
|
21
|
+
if (match = part.match(/\A(\d+)\s*-\s*(\d+)\z/))
|
|
22
|
+
low, high = match.captures.map(&:to_i)
|
|
23
|
+
low <= high ? (low..high).to_a : []
|
|
24
|
+
elsif part.match?(/\A\d+\z/)
|
|
25
|
+
[part.to_i]
|
|
26
|
+
else
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
end.uniq.sort
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module RateCard
|
|
7
|
+
# Everything one run of the tool needs to know, validated. The wizard's sole
|
|
8
|
+
# output; every component downstream consumes only this. Nothing here prompts
|
|
9
|
+
# or performs I/O.
|
|
10
|
+
#
|
|
11
|
+
# Written as a subclass of an anonymous Struct rather than `Struct.new do ... end`
|
|
12
|
+
# on purpose. Constant *assignment* inside a Struct block resolves lexically, so
|
|
13
|
+
# RATE_KEY_FIELDS there lands on RateCard, not on RunSpec — silently, with no
|
|
14
|
+
# warning — and every external `RunSpec::RATE_KEY_FIELDS` reference then raises
|
|
15
|
+
# NameError. A real class body puts the constants where they belong.
|
|
16
|
+
class RunSpec < Struct.new(
|
|
17
|
+
:token, :customer_name, :customer_id, :carrier, :services, :zones,
|
|
18
|
+
:weight_unit, :weights, :package_type, :rate_keys, :output_base,
|
|
19
|
+
:show_table, :started_at, :rate_mode, :cubic_tiers,
|
|
20
|
+
keyword_init: true
|
|
21
|
+
)
|
|
22
|
+
# Our rate-key name => the field it arrives as in service_rates. Getting
|
|
23
|
+
# this backwards would put meter rates in the shipper-rate column: a
|
|
24
|
+
# plausible-looking, entirely wrong rate card.
|
|
25
|
+
RATE_KEY_FIELDS = { shipper_rate: 'rate', meter_rate: 'meter_rate' }.freeze
|
|
26
|
+
RATE_KEY_LABELS = { shipper_rate: 'shipper rate', meter_rate: 'meter rate' }.freeze
|
|
27
|
+
WEIGHT_UNITS = %i[oz lbs].freeze
|
|
28
|
+
|
|
29
|
+
# One call returns rates for every enabled service, so service count does
|
|
30
|
+
# not affect call volume.
|
|
31
|
+
def call_count
|
|
32
|
+
rows.length * zones.length
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def service_names
|
|
36
|
+
services.map(&:name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rate_key_labels
|
|
40
|
+
rate_keys.map { |key| rate_key_label(key) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def zone_summary
|
|
44
|
+
self.class.compact_range(zones)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# [1,2,3,5] => "1-3,5". Runs of three or more collapse; a pair stays listed,
|
|
48
|
+
# since "4,5" is no longer than "4-5" and reads as what the user typed.
|
|
49
|
+
def self.compact_range(numbers)
|
|
50
|
+
sorted = numbers.to_a.sort.uniq
|
|
51
|
+
runs = sorted.slice_when { |a, b| b != a + 1 }.to_a
|
|
52
|
+
runs.map { |run| run.length >= 3 ? "#{run.first}-#{run.last}" : run.join(',') }.join(',')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The API always takes ounces; the chosen unit is an input concern only.
|
|
56
|
+
def weight_in_oz(weight)
|
|
57
|
+
weight_unit == :lbs ? weight * 16 : weight
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Weight mode when not set, so every existing caller — none of which
|
|
61
|
+
# mentions rate_mode — keeps building a weight x zone card exactly as
|
|
62
|
+
# before.
|
|
63
|
+
def rate_mode
|
|
64
|
+
self[:rate_mode] || :weight
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The row axis Grid/CsvWriter/TableRenderer actually sweep: integer
|
|
68
|
+
# weights in weight mode, selected USPS cubic tier ids in cubic mode.
|
|
69
|
+
# Downstream code reads only this plus #row_label and #row_header, so
|
|
70
|
+
# neither has to know which mode produced it.
|
|
71
|
+
def rows
|
|
72
|
+
rate_mode == :cubic ? cubic_tiers : weights
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def row_label(row)
|
|
76
|
+
rate_mode == :cubic ? Constants::CubicTiers.label(row) : row.to_s
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def row_header
|
|
80
|
+
rate_mode == :cubic ? 'cubic_tier' : 'weight'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The weight actually sent on the wire for one row: the tier's fixed
|
|
84
|
+
# cap in cubic mode (cubic pricing is volume-driven, not weight-driven,
|
|
85
|
+
# so the display weight_unit plays no part), or the usual oz/lbs
|
|
86
|
+
# conversion in weight mode.
|
|
87
|
+
def weight_in_oz_for(row)
|
|
88
|
+
rate_mode == :cubic ? Constants::CubicTiers.weight_oz(row) : weight_in_oz(row)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# nil in weight mode, so Shipment keeps its own fixed nominal box.
|
|
92
|
+
def dims_for(row)
|
|
93
|
+
rate_mode == :cubic ? Constants::CubicTiers.dims(row) : nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_rows!
|
|
97
|
+
if rate_mode == :cubic
|
|
98
|
+
raise ArgumentError, 'select at least one cubic tier' if cubic_tiers.nil? || cubic_tiers.empty?
|
|
99
|
+
|
|
100
|
+
unknown = cubic_tiers - Constants::CubicTiers.ids
|
|
101
|
+
raise ArgumentError, "unknown cubic tier: #{unknown.join(', ')}" unless unknown.empty?
|
|
102
|
+
else
|
|
103
|
+
raise ArgumentError, 'select at least one weight' if weights.nil? || weights.empty?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def address_for(zone)
|
|
108
|
+
Constants::Addresses.for_carrier(carrier)[zone]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def rate_key_label(key)
|
|
112
|
+
RATE_KEY_LABELS.fetch(key, key.to_s.tr('_', ' '))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def weight_label
|
|
116
|
+
rate_mode == :cubic ? 'cubic tier' : "wt(#{weight_unit})"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Apostrophes are dropped rather than collapsed, so "Bob's" slugs to "bobs"
|
|
120
|
+
# and not "bob_s". Everything else non-alphanumeric becomes a single
|
|
121
|
+
# underscore. Note the customer name is usually an email address, since eHub
|
|
122
|
+
# tokens carry no customer name.
|
|
123
|
+
def slug
|
|
124
|
+
cleaned = customer_name.to_s.downcase
|
|
125
|
+
.gsub(/['’`]/, '')
|
|
126
|
+
.gsub(/[^a-z0-9]+/, '_')
|
|
127
|
+
.gsub(/\A_+|_+\z/, '')
|
|
128
|
+
|
|
129
|
+
# Token always supplies a non-blank name, but an empty slug would build a
|
|
130
|
+
# directory called '_1042_...', so do not rely on that from here.
|
|
131
|
+
cleaned.empty? ? 'customer' : cleaned
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def timestamp
|
|
135
|
+
started_at.utc.strftime('%Y-%m-%dT%H-%M-%SZ')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def run_dir
|
|
139
|
+
Pathname.new(output_base).join("#{slug}_#{customer_id}_#{timestamp}")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate!
|
|
143
|
+
raise ArgumentError, 'select at least one service' if services.nil? || services.empty?
|
|
144
|
+
raise ArgumentError, 'select at least one zone' if zones.nil? || zones.empty?
|
|
145
|
+
validate_rows!
|
|
146
|
+
raise ArgumentError, 'select at least one rate column' if rate_keys.nil? || rate_keys.empty?
|
|
147
|
+
|
|
148
|
+
unknown = rate_keys - RATE_KEY_FIELDS.keys
|
|
149
|
+
raise ArgumentError, "unknown rate column: #{unknown.join(', ')}" unless unknown.empty?
|
|
150
|
+
|
|
151
|
+
unless WEIGHT_UNITS.include?(weight_unit)
|
|
152
|
+
raise ArgumentError, "weight unit must be one of #{WEIGHT_UNITS.join(', ')}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
zones.each do |zone|
|
|
156
|
+
next if address_for(zone)
|
|
157
|
+
|
|
158
|
+
raise ArgumentError, "no address for zone #{zone} on carrier #{carrier}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
self
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
# Reports one completed run: render, write, report. Returns the process exit
|
|
5
|
+
# code.
|
|
6
|
+
#
|
|
7
|
+
# The fetch itself moved into TUI::App when this switched to Bubbletea — it
|
|
8
|
+
# has to happen inside the event loop for the progress bar to be live — so
|
|
9
|
+
# this now takes the finished Grid rather than building one. Everything here
|
|
10
|
+
# runs after the terminal is back to normal, which is why it is all plain
|
|
11
|
+
# printing.
|
|
12
|
+
class Runner
|
|
13
|
+
def initialize(spec:, grid:, ui:)
|
|
14
|
+
@spec = spec
|
|
15
|
+
@grid = grid
|
|
16
|
+
@ui = ui
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
@ui.print_tables(TableRenderer.new(grid: grid, spec: spec).tables) if spec.show_table
|
|
21
|
+
@ui.failure_report(grid.failures, spec: spec)
|
|
22
|
+
@ui.warning_report(grid.warnings)
|
|
23
|
+
|
|
24
|
+
if grid.all_failed?
|
|
25
|
+
@ui.error('every rate call failed — no files written')
|
|
26
|
+
return 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
unless grid.any_rates?
|
|
30
|
+
@ui.error('the calls succeeded but returned no rates for the selected ' \
|
|
31
|
+
'services — check the service, package type and weight range')
|
|
32
|
+
return 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@ui.saved(CsvWriter.new(grid: grid, spec: spec).write)
|
|
36
|
+
0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :spec, :grid
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
# One shipping service the customer has enabled, as discovered from a probe call.
|
|
5
|
+
Service = Struct.new(:id, :code, :name, :carrier, :package_types, keyword_init: true) do
|
|
6
|
+
# The package types this service accepts, from services[].package_types.
|
|
7
|
+
# Always an array: callers offer these as choices, and a nil would have to
|
|
8
|
+
# be guarded at every one of them.
|
|
9
|
+
def package_types
|
|
10
|
+
Array(self[:package_types])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# What the wizard checkbox shows.
|
|
14
|
+
def label
|
|
15
|
+
"#{name} (#{id})"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Basis for output filenames; must be filesystem-safe and never empty.
|
|
19
|
+
#
|
|
20
|
+
# Falls through on the SANITISED value, not the raw one: a code like '###'
|
|
21
|
+
# is non-empty but sanitises to nothing, and an empty slug would produce a
|
|
22
|
+
# file named '_shipper_rate.csv' on the user's disk.
|
|
23
|
+
def file_slug
|
|
24
|
+
[code, name].each do |candidate|
|
|
25
|
+
slug = sanitize(candidate)
|
|
26
|
+
return slug unless slug.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sanitize("service_#{id}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def sanitize(value)
|
|
35
|
+
value.to_s.gsub(/[^A-Za-z0-9]+/, '_').gsub(/\A_+|_+\z/, '')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
# Turns the /services response into the list of services the wizard can offer.
|
|
5
|
+
# Read live rather than from a hardcoded list so a service id that has drifted
|
|
6
|
+
# cannot silently produce a card of blank cells.
|
|
7
|
+
module ServiceCatalog
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Returns Array<Service>, de-duplicated and sorted for display.
|
|
11
|
+
def from_response(body)
|
|
12
|
+
entries = body.is_a?(Hash) ? (body['services'] || []) : []
|
|
13
|
+
|
|
14
|
+
services = entries.filter_map { |entry| build_service(entry) }.uniq(&:id)
|
|
15
|
+
sort_for_display(services)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns { carrier => Array<Service> } with carriers in display order.
|
|
19
|
+
def group_by_carrier(services)
|
|
20
|
+
services.group_by(&:carrier)
|
|
21
|
+
.sort_by { |carrier, _| carrier_rank(carrier) }
|
|
22
|
+
.to_h
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_service(entry)
|
|
26
|
+
id = entry['service_id']
|
|
27
|
+
return nil if id.nil?
|
|
28
|
+
|
|
29
|
+
code = entry['service_code'].to_s
|
|
30
|
+
name = entry['service'].to_s
|
|
31
|
+
name = code if name.strip.empty?
|
|
32
|
+
|
|
33
|
+
Service.new(
|
|
34
|
+
id: id.to_i,
|
|
35
|
+
code: code,
|
|
36
|
+
name: name,
|
|
37
|
+
carrier: Constants::Carriers.for_carrier_code(entry['carrier_code']),
|
|
38
|
+
package_types: package_types(entry)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# services[].package_types is an array of {type, name}; only the type is
|
|
43
|
+
# sent back in a rate request.
|
|
44
|
+
def package_types(entry)
|
|
45
|
+
Array(entry['package_types']).filter_map do |package_type|
|
|
46
|
+
next package_type.to_s unless package_type.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
type = package_type['type'].to_s
|
|
49
|
+
type.empty? ? nil : type
|
|
50
|
+
end.uniq
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sort_for_display(services)
|
|
54
|
+
services.sort_by { |service| [carrier_rank(service.carrier), service.name.to_s] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def carrier_rank(carrier)
|
|
58
|
+
index = Constants::Carriers.display_order.index(carrier)
|
|
59
|
+
index || Constants::Carriers.display_order.length
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|