rate-card 0.1.5 → 0.1.6
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/lib/rate_card/constants/addresses.rb +69 -0
- data/lib/rate_card/constants/carriers.rb +40 -2
- data/lib/rate_card/grid.rb +2 -1
- data/lib/rate_card/providers/ehub/client.rb +23 -6
- data/lib/rate_card/providers/ehub/provider.rb +2 -2
- data/lib/rate_card/providers/ehub/shipment.rb +8 -3
- data/lib/rate_card/run_spec.rb +33 -2
- data/lib/rate_card/tui/app.rb +264 -34
- data/lib/rate_card/tui/fields/multi_select.rb +14 -6
- data/lib/rate_card/tui/fields/select.rb +15 -7
- data/lib/rate_card/tui/fields/text.rb +10 -1
- data/lib/rate_card/tui/theme.rb +50 -6
- data/lib/rate_card/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 26756dd90159acde8bf444c7b7a4de2ee99c4b7b05c87a01a3f4946b3e920bb8
|
|
4
|
+
data.tar.gz: 0a2f73702f26ef311b8c729951f21e72b9641893b566de5928dbb5ba2aacd5b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c891d9491881bad2b00b8c5b143ef3118699c6607e12449f653129d9d94e5a716f61b655f26988f0a53a01eaada736bf3e5d12afce72fcd685479d025179b1c
|
|
7
|
+
data.tar.gz: 06aec0434e85290036bfae0f3a672c597770d5519ecf89330751b16e53f44017bf5fdd271ed9e74e6e978653c57d0c358dbc9a87066eb024a1bfd42dffe1b37e
|
|
@@ -106,6 +106,75 @@ module RateCard
|
|
|
106
106
|
# mode sweeps surcharge types instead of zones — see RunSpec#address_for.
|
|
107
107
|
UPS_RURAL = { edas: UPS_EDAS_EXCEPTION, rdas: UPS_RDAS_EXCEPTION }.freeze
|
|
108
108
|
|
|
109
|
+
# UNVERIFIED — real public post-office addresses, one per origin ZIP3
|
|
110
|
+
# that Usps::IntlGroup1ZoneCalculator (ehub app/services/usps/
|
|
111
|
+
# intl_group1_zone_calculator.rb, backed by
|
|
112
|
+
# files/usps_intl_canada_zones.json.gz) maps to each zone 1-8.
|
|
113
|
+
#
|
|
114
|
+
# USPS's Canada rate is origin-driven, not destination-driven like the
|
|
115
|
+
# domestic charts above: the zone comes from the FROM zip's first 3
|
|
116
|
+
# digits, and the destination only needs to be a real address in
|
|
117
|
+
# Canada (see the spec fixtures in usps/rate_calculator_spec.rb, which
|
|
118
|
+
# call rate(from_zip, 'CA', ...) - no street-level destination enters
|
|
119
|
+
# the calculation at all). That inverts RunSpec#address_for's usual
|
|
120
|
+
# "vary destination, fix origin" shape for this one carrier/country
|
|
121
|
+
# pair - see #origin_for.
|
|
122
|
+
#
|
|
123
|
+
# Addresses are real (each is a public USPS post office and its ZIP3
|
|
124
|
+
# was confirmed against the actual origin table, not just "a nearby
|
|
125
|
+
# city"), but UNVERIFIED means the ZIP3 -> zone mapping itself hasn't
|
|
126
|
+
# been re-derived from files/usps_intl_canada_zones.json.gz by a
|
|
127
|
+
# second person - do this before trusting a rate card built from it.
|
|
128
|
+
USPS_CANADA_ORIGINS = {
|
|
129
|
+
1 => { address1: '1040 Waverly Ave', city: 'Holtsville', state: 'NY', postal_code: '00501', country: 'US' },
|
|
130
|
+
2 => { address1: '600 Suffield St', city: 'Agawam', state: 'MA', postal_code: '01001', country: 'US' },
|
|
131
|
+
3 => { address1: '462 Washington St', city: 'Woburn', state: 'MA', postal_code: '01801', country: 'US' },
|
|
132
|
+
4 => { address1: '73 Hammond St Ste 9998', city: 'Bangor', state: 'ME', postal_code: '04401', country: 'US' },
|
|
133
|
+
5 => { address1: '83 Broad St', city: 'Charleston', state: 'SC', postal_code: '29401', country: 'US' },
|
|
134
|
+
6 => { address1: '50 Carr 459', city: 'Aguadilla', state: 'PR', postal_code: '00603', country: 'US' },
|
|
135
|
+
7 => { address1: '709 W 9th St', city: 'Juneau', state: 'AK', postal_code: '99801', country: 'US' },
|
|
136
|
+
8 => { address1: '99-040 Kauhale St', city: 'Aiea', state: 'HI', postal_code: '96701', country: 'US' }
|
|
137
|
+
}.freeze
|
|
138
|
+
|
|
139
|
+
# UNVERIFIED — the fixed Canadian destination for every USPS_CANADA_ORIGINS
|
|
140
|
+
# zone. Ported from INTERNATIONAL_UNVERIFIED zone 0 below. Safe to reuse
|
|
141
|
+
# across all 8 zones only because USPS's Canada rate doesn't depend on
|
|
142
|
+
# destination specifics - see USPS_CANADA_ORIGINS above. Do not reuse
|
|
143
|
+
# this reasoning for any other carrier without checking that carrier's
|
|
144
|
+
# own calculator the same way.
|
|
145
|
+
USPS_CANADA_DESTINATION = { address1: '6990 Victoria Dr', city: 'Vancouver', state: 'BC',
|
|
146
|
+
postal_code: 'V5P 3Y8', country: 'CA' }.freeze
|
|
147
|
+
|
|
148
|
+
# UNVERIFIED — ported as-is from ../rate_table_builder/constants/address_constants.rb
|
|
149
|
+
# (INTL_ADDRESSES, originally commented "# International?"). Not
|
|
150
|
+
# confirmed against any carrier's actual international zone chart, and
|
|
151
|
+
# not carrier-specific: the source file left international: nil for
|
|
152
|
+
# USPS, UPS, and FedEx alike, so these zone numbers are not known to
|
|
153
|
+
# correspond to DHL Express, DHL eCommerce, or USPS Priority Mail
|
|
154
|
+
# International zone boundaries. Do not wire this into BY_CARRIER or
|
|
155
|
+
# for_carrier until each entry is hand-verified against a real
|
|
156
|
+
# carrier zone chart, the way the domestic addresses above are.
|
|
157
|
+
INTERNATIONAL_UNVERIFIED = [
|
|
158
|
+
{ zone: 0, address1: '6990 Victoria Dr', city: 'Vancouver', state: 'Vancouver', postal_code: 'V5P 3Y8', country: 'CA' },
|
|
159
|
+
{ zone: 1, address1: '1249 Metcalfe St', city: 'Montreal', state: 'Montreal', postal_code: 'H3B 2V5', country: 'CA' },
|
|
160
|
+
{ zone: 2, address1: '10416 80 Ave NW', city: 'Edmonton', state: 'Edmonton', postal_code: 'T6E 5T7', country: 'CA' },
|
|
161
|
+
{ zone: 3, address1: '4910 52 St', city: 'Yellowknife', state: 'Yellowknife', postal_code: 'X1A 1T3', country: 'CA' },
|
|
162
|
+
{ zone: 4, address1: '179 Shaftesbury Ave', city: 'London', state: 'London', postal_code: 'WC2H 8JR', country: 'GB' },
|
|
163
|
+
{ zone: 5, address1: 'R Sao Joaquim, 381 - Liberdade', city: 'Sao Paulo', state: 'Sao Paulo', postal_code: '01508-001', country: 'BR' },
|
|
164
|
+
{ zone: 6, address1: '10 Bligh St', city: 'Sydney', state: 'Sydney', postal_code: '2000', country: 'AU' },
|
|
165
|
+
{ zone: 7, address1: 'Rudi-Dutschke-Strasse 26', city: 'Berlin', state: 'Berlin', postal_code: '10969', country: 'DE' },
|
|
166
|
+
{ zone: 8, address1: '107 Rue de Rivoli', city: 'Paris', state: 'Paris', postal_code: '75001', country: 'FR' },
|
|
167
|
+
{ zone: 9, address1: 'Colima 150, Roma Nte.', city: 'Mexico City', state: 'Mexico City', postal_code: '06700', country: 'MX' },
|
|
168
|
+
{ zone: 10, address1: '18 Merrion Row', city: 'Dublin', state: 'Dublin', postal_code: 'D02 A316', country: 'IE' },
|
|
169
|
+
{ zone: 11, address1: '3 Chome-4 Kagurazaka', city: 'Tokyo', state: 'Tokyo', postal_code: '162-0825', country: 'JP' },
|
|
170
|
+
{ zone: 12, address1: '90 Wellesley Street West', city: 'Auckland', state: 'Auckland', postal_code: '1010', country: 'NZ' },
|
|
171
|
+
{ zone: 13, address1: 'Munsterhof 12,', city: 'Zurich', state: 'Zurich', postal_code: '8001', country: 'CH' },
|
|
172
|
+
{ zone: 14, address1: 'Daniel Stalpertstraat 103', city: 'Amsterdam', state: 'Amsterdam', postal_code: '1072', country: 'NL' },
|
|
173
|
+
{ zone: 15, address1: '404 Crescent Business Park Link Road Andheri', city: 'Mumbai', state: 'Maharashtra', postal_code: '400072', country: 'IN' },
|
|
174
|
+
{ zone: 16, address1: 'C. del Prado, 16', city: 'Madrid', state: 'Madrid', postal_code: '28014', country: 'ES' },
|
|
175
|
+
{ zone: 17, address1: 'Piazza Pasquale Paoli 15', city: 'Roma', state: 'Roma', postal_code: '00186', country: 'IT' }
|
|
176
|
+
].freeze
|
|
177
|
+
|
|
109
178
|
module_function
|
|
110
179
|
|
|
111
180
|
# Raises rather than falling back. The old USPS default meant asking for
|
|
@@ -7,7 +7,8 @@ module RateCard
|
|
|
7
7
|
module Carriers
|
|
8
8
|
OTHER = 'Other'
|
|
9
9
|
|
|
10
|
-
DISPLAY_ORDER = ['USPS', 'UPS', 'FedEx', 'DHL', 'OSM', '
|
|
10
|
+
DISPLAY_ORDER = ['USPS', 'UPS', 'FedEx', 'DHL', 'OSM', 'CDL', 'GLS', 'SpeedX',
|
|
11
|
+
'UniUni', 'Veho', 'Dragonfly', 'Amazon', OTHER].freeze
|
|
11
12
|
|
|
12
13
|
# Every /services entry carries a lowercase carrier_code. Anything not
|
|
13
14
|
# listed here keeps its own code, upcased: an unrecognised carrier is
|
|
@@ -27,7 +28,10 @@ module RateCard
|
|
|
27
28
|
# silently inherit the wrong zone chart.
|
|
28
29
|
CARRIER_CODES = {
|
|
29
30
|
'usps' => 'USPS', 'ups' => 'UPS', 'fedex' => 'FedEx',
|
|
30
|
-
'dhl' => 'DHL Express', 'dhl_ecommerce' => 'DHL', 'osm' => 'OSM',
|
|
31
|
+
'dhl' => 'DHL Express', 'dhl_ecommerce' => 'DHL', 'osm' => 'OSM',
|
|
32
|
+
'cdl' => 'CDL', 'gls' => 'GLS', 'speedx' => 'SpeedX',
|
|
33
|
+
'uniuni' => 'UniUni', 'veho' => 'Veho', 'dragonfly' => 'Dragonfly',
|
|
34
|
+
'amazon' => 'Amazon'
|
|
31
35
|
}.freeze
|
|
32
36
|
|
|
33
37
|
# Carriers with a rural/DAS surcharge test address in Constants::Addresses.
|
|
@@ -35,6 +39,32 @@ module RateCard
|
|
|
35
39
|
# chart and no rural chart at all.
|
|
36
40
|
RURAL_AWARE = %w[USPS UPS].freeze
|
|
37
41
|
|
|
42
|
+
# Carriers with no entry in Addresses::BY_CARRIER — their zone chart would
|
|
43
|
+
# be fetched live per run via Providers::EHub::Client#fetch_zone_addresses
|
|
44
|
+
# instead of read from a hand-verified constant. See
|
|
45
|
+
# 2026-09-03-dynamic-zone-addresses-design.md. OLX is deliberately not
|
|
46
|
+
# here — no live OLX rating API exists in ehub.
|
|
47
|
+
#
|
|
48
|
+
# Disabled for now: the zone_addresses endpoint does not exist yet in
|
|
49
|
+
# ehub-api, so these carriers report as unsupported (like any other
|
|
50
|
+
# carrier with no chart) rather than attempting a call that always 404s.
|
|
51
|
+
# Restore this list once the endpoint ships.
|
|
52
|
+
LIVE_CHART = [].freeze
|
|
53
|
+
|
|
54
|
+
# Carriers with an international destination in Constants::Addresses
|
|
55
|
+
# (USPS_CANADA_ORIGINS/USPS_CANADA_DESTINATION today). Kept explicit
|
|
56
|
+
# rather than inferring from chart presence, same reasoning as
|
|
57
|
+
# RURAL_AWARE — a carrier can support domestic long before any
|
|
58
|
+
# international country is verified for it.
|
|
59
|
+
#
|
|
60
|
+
# Empty for now: USPS_CANADA_ORIGINS' ZIP3 -> zone mapping is still
|
|
61
|
+
# UNVERIFIED (see Constants::Addresses), so the Destination prompt is
|
|
62
|
+
# hidden from the wizard until a second person re-derives it against
|
|
63
|
+
# files/usps_intl_canada_zones.json.gz. Add 'USPS' back once that's
|
|
64
|
+
# done — country_field, zones_field, and RunSpec#origin_for are
|
|
65
|
+
# already wired and need no other change.
|
|
66
|
+
INTERNATIONAL_AWARE = [].freeze
|
|
67
|
+
|
|
38
68
|
module_function
|
|
39
69
|
|
|
40
70
|
# OTHER is only for a malformed entry with no code at all, so a service is
|
|
@@ -53,6 +83,14 @@ module RateCard
|
|
|
53
83
|
def rural_aware?(carrier)
|
|
54
84
|
RURAL_AWARE.include?(carrier.to_s)
|
|
55
85
|
end
|
|
86
|
+
|
|
87
|
+
def live_chart?(carrier)
|
|
88
|
+
LIVE_CHART.include?(carrier.to_s)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def international_aware?(carrier)
|
|
92
|
+
INTERNATIONAL_AWARE.include?(carrier.to_s)
|
|
93
|
+
end
|
|
56
94
|
end
|
|
57
95
|
end
|
|
58
96
|
end
|
data/lib/rate_card/grid.rb
CHANGED
|
@@ -71,7 +71,8 @@ module RateCard
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def fetch_cell(client, weight, zone, retry_sleeper)
|
|
74
|
-
payload = provider.build_payload(spec: spec, weight: weight, address: spec.address_for(zone)
|
|
74
|
+
payload = provider.build_payload(spec: spec, weight: weight, address: spec.address_for(zone),
|
|
75
|
+
origin: spec.origin_for(zone))
|
|
75
76
|
result = fetch_with_transient_retry(client, payload, retry_sleeper)
|
|
76
77
|
record_response(result, weight, zone)
|
|
77
78
|
@mutex.synchronize { @succeeded += 1 }
|
|
@@ -16,10 +16,15 @@ module RateCard
|
|
|
16
16
|
# full of blank cells instead of one clear error — do not do that.
|
|
17
17
|
class Client
|
|
18
18
|
BASE_URL = 'https://api.ehub.com'
|
|
19
|
+
# Zone-addresses defaults to the local server that has the fix in
|
|
20
|
+
# progress, while rates/services stay on production. Override with
|
|
21
|
+
# RATE_CARD_ZONE_ADDRESSES_URL (e.g. back to BASE_URL once the fix ships).
|
|
22
|
+
ZONE_ADDRESSES_BASE_URL = ENV.fetch('RATE_CARD_ZONE_ADDRESSES_URL', 'http://localhost:9000')
|
|
19
23
|
# No trailing slash: that is the documented path, and a trailing slash would
|
|
20
24
|
# rely on a redirect — which can drop the POST body.
|
|
21
25
|
RATES_PATH = '/api/v2/rates'
|
|
22
26
|
SERVICES_PATH = '/api/v2/services'
|
|
27
|
+
ZONE_ADDRESSES_PATH = '/api/v2/services/%<service_id>s/zone_addresses'
|
|
23
28
|
# 'ecommerce' services are storefront integrations, not shippable rates.
|
|
24
29
|
SERVICES_CATEGORY = 'shipping'
|
|
25
30
|
|
|
@@ -42,7 +47,8 @@ module RateCard
|
|
|
42
47
|
@token = token
|
|
43
48
|
@stubs = stubs
|
|
44
49
|
@sleeper = sleeper
|
|
45
|
-
@connection = build_connection
|
|
50
|
+
@connection = build_connection(BASE_URL)
|
|
51
|
+
@zone_addresses_connection = ZONE_ADDRESSES_BASE_URL == BASE_URL ? @connection : build_connection(ZONE_ADDRESSES_BASE_URL)
|
|
46
52
|
end
|
|
47
53
|
|
|
48
54
|
# Returns the parsed response body as a Hash.
|
|
@@ -54,7 +60,18 @@ module RateCard
|
|
|
54
60
|
# Discovery uses this rather than a rate call: it costs nothing, and it lists
|
|
55
61
|
# services that would not have quoted at the single probe weight and zone.
|
|
56
62
|
def fetch_services
|
|
57
|
-
request('service list call') { get(SERVICES_PATH, category: SERVICES_CATEGORY) }
|
|
63
|
+
request('service list call') { get(SERVICES_PATH, { category: SERVICES_CATEGORY }) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the parsed { "zones" => { "1" => {address1:, city:, ...}, ... } }
|
|
67
|
+
# body for one service's live zone chart. from_postal_code is only
|
|
68
|
+
# meaningful for a fixed-origin carrier (e.g. GLS) — omitted params
|
|
69
|
+
# means the server resolves the origin from account configuration.
|
|
70
|
+
def fetch_zone_addresses(service_id, from_postal_code: nil)
|
|
71
|
+
params = from_postal_code ? { from_postal_code: from_postal_code } : {}
|
|
72
|
+
request('zone addresses call') do
|
|
73
|
+
get(format(ZONE_ADDRESSES_PATH, service_id: service_id), params, connection: @zone_addresses_connection)
|
|
74
|
+
end
|
|
58
75
|
end
|
|
59
76
|
|
|
60
77
|
private
|
|
@@ -89,8 +106,8 @@ module RateCard
|
|
|
89
106
|
raise RequestFailed, "rate call failed: #{e.message}"
|
|
90
107
|
end
|
|
91
108
|
|
|
92
|
-
def get(path, params)
|
|
93
|
-
response =
|
|
109
|
+
def get(path, params, connection: @connection)
|
|
110
|
+
response = connection.get(path, params)
|
|
94
111
|
[response.status, parse(response.body)]
|
|
95
112
|
rescue Faraday::Error => e
|
|
96
113
|
raise RequestFailed, "service list call failed: #{e.message}"
|
|
@@ -109,8 +126,8 @@ module RateCard
|
|
|
109
126
|
message ? " (#{message})" : ''
|
|
110
127
|
end
|
|
111
128
|
|
|
112
|
-
def build_connection
|
|
113
|
-
Faraday.new(url:
|
|
129
|
+
def build_connection(url)
|
|
130
|
+
Faraday.new(url: url) do |f|
|
|
114
131
|
f.headers['Authorization'] = "Bearer #{@token}"
|
|
115
132
|
f.headers['Content-Type'] = 'application/json'
|
|
116
133
|
f.options.timeout = 30
|
|
@@ -35,8 +35,8 @@ module RateCard
|
|
|
35
35
|
ServiceCatalog.from_response(body)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def build_payload(spec:, weight:, address:)
|
|
39
|
-
Shipment.new(spec: spec, weight: weight, address: address).payload
|
|
38
|
+
def build_payload(spec:, weight:, address:, origin: nil)
|
|
39
|
+
Shipment.new(spec: spec, weight: weight, address: address, origin: origin).payload
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
# Normalizes one /rates response into what Grid needs to record a cell:
|
|
@@ -14,16 +14,21 @@ module RateCard
|
|
|
14
14
|
ITEM_VALUE = 18.99
|
|
15
15
|
HS_TARIFF_CODE = '1704.90.3000'
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# origin: nil for every carrier except USPS->Canada, where the zone is
|
|
18
|
+
# a property of the origin rather than the destination — see
|
|
19
|
+
# RunSpec#origin_for. Falls back to the usual fixed shipment origin
|
|
20
|
+
# when nil, so every other carrier is unaffected.
|
|
21
|
+
def initialize(spec:, weight:, address:, origin: nil)
|
|
18
22
|
@spec = spec
|
|
19
23
|
@weight = weight
|
|
20
24
|
@address = address
|
|
25
|
+
@origin = origin
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def payload
|
|
24
29
|
{
|
|
25
30
|
shipment: {
|
|
26
|
-
from_location: Constants::Addresses::ORIGIN,
|
|
31
|
+
from_location: origin || Constants::Addresses::ORIGIN,
|
|
27
32
|
to_location: to_location,
|
|
28
33
|
parcels: [parcel]
|
|
29
34
|
}
|
|
@@ -32,7 +37,7 @@ module RateCard
|
|
|
32
37
|
|
|
33
38
|
private
|
|
34
39
|
|
|
35
|
-
attr_reader :spec, :weight, :address
|
|
40
|
+
attr_reader :spec, :weight, :address, :origin
|
|
36
41
|
|
|
37
42
|
def to_location
|
|
38
43
|
{
|
data/lib/rate_card/run_spec.rb
CHANGED
|
@@ -17,6 +17,8 @@ module RateCard
|
|
|
17
17
|
:token, :customer_name, :customer_id, :carrier, :services, :zones,
|
|
18
18
|
:weight_unit, :weights, :package_type, :rate_keys, :output_base,
|
|
19
19
|
:show_table, :started_at, :rate_mode, :cubic_tiers, :rural,
|
|
20
|
+
:zone_chart, # nil for the 5 static-chart carriers, populated for the live ones
|
|
21
|
+
:country, # nil for domestic; e.g. 'CA' for USPS->Canada — see #origin_for
|
|
20
22
|
keyword_init: true
|
|
21
23
|
)
|
|
22
24
|
# Our rate-key name => the field it arrives as in service_rates. Getting
|
|
@@ -54,6 +56,13 @@ module RateCard
|
|
|
54
56
|
!!rural
|
|
55
57
|
end
|
|
56
58
|
|
|
59
|
+
# true only for USPS->Canada today. nil (domestic, or any other carrier)
|
|
60
|
+
# is falsy here — every existing caller keeps seeing plain domestic
|
|
61
|
+
# behavior.
|
|
62
|
+
def international?
|
|
63
|
+
!!country
|
|
64
|
+
end
|
|
65
|
+
|
|
57
66
|
# [1,2,3,5] => "1-3,5". Runs of three or more collapse; a pair stays listed,
|
|
58
67
|
# since "4,5" is no longer than "4-5" and reads as what the user typed.
|
|
59
68
|
#
|
|
@@ -124,13 +133,34 @@ module RateCard
|
|
|
124
133
|
# disambiguates from a real zone number without needing to check carrier
|
|
125
134
|
# or rural separately. USPS rural mode keeps integer zones, just against
|
|
126
135
|
# the rural chart instead of the normal one.
|
|
136
|
+
# USPS->Canada is destination-fixed, origin-varies — the opposite of
|
|
137
|
+
# every other chart here (see #origin_for) — so this returns the one
|
|
138
|
+
# Canadian destination for every zone rather than looking one up per
|
|
139
|
+
# zone.
|
|
127
140
|
def address_for(zone)
|
|
128
141
|
return Constants::Addresses::UPS_RURAL[zone] if zone.is_a?(Symbol)
|
|
129
142
|
return Constants::Addresses::USPS_RURAL_DAS[zone] if carrier == 'USPS' && rural?
|
|
143
|
+
return Constants::Addresses::USPS_CANADA_DESTINATION if usps_canada?
|
|
144
|
+
return zone_chart[zone] if zone_chart
|
|
130
145
|
|
|
131
146
|
Constants::Addresses.for_carrier(carrier)[zone]
|
|
132
147
|
end
|
|
133
148
|
|
|
149
|
+
# nil for every carrier/zone except USPS->Canada, where the zone is a
|
|
150
|
+
# property of the origin ZIP3 (Usps::IntlGroup1ZoneCalculator), not the
|
|
151
|
+
# destination — see Constants::Addresses::USPS_CANADA_ORIGINS. Shipment
|
|
152
|
+
# falls back to the usual fixed Constants::Addresses::ORIGIN when this
|
|
153
|
+
# is nil, so every other carrier is unaffected.
|
|
154
|
+
def origin_for(zone)
|
|
155
|
+
return nil unless usps_canada?
|
|
156
|
+
|
|
157
|
+
Constants::Addresses::USPS_CANADA_ORIGINS[zone]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def usps_canada?
|
|
161
|
+
carrier == 'USPS' && country == 'CA'
|
|
162
|
+
end
|
|
163
|
+
|
|
134
164
|
def rate_key_label(key)
|
|
135
165
|
RATE_KEY_LABELS.fetch(key, key.to_s.tr('_', ' '))
|
|
136
166
|
end
|
|
@@ -168,6 +198,7 @@ module RateCard
|
|
|
168
198
|
# run_dir. USPS rural mode sweeps real zones as usual, so it just gets
|
|
169
199
|
# the generic suffix.
|
|
170
200
|
def rural_suffix
|
|
201
|
+
return "_#{country}" if international?
|
|
171
202
|
return '' unless rural?
|
|
172
203
|
|
|
173
204
|
surcharge = zones.find { |zone| zone.is_a?(Symbol) }
|
|
@@ -188,9 +219,9 @@ module RateCard
|
|
|
188
219
|
end
|
|
189
220
|
|
|
190
221
|
zones.each do |zone|
|
|
191
|
-
next if address_for(zone)
|
|
222
|
+
next if address_for(zone) && (!usps_canada? || origin_for(zone))
|
|
192
223
|
|
|
193
|
-
raise ArgumentError, "no address for zone #{zone} on carrier #{carrier}"
|
|
224
|
+
raise ArgumentError, "no address for zone #{zone} on carrier #{carrier}#{" (#{country})" if country}"
|
|
194
225
|
end
|
|
195
226
|
|
|
196
227
|
self
|
data/lib/rate_card/tui/app.rb
CHANGED
|
@@ -24,8 +24,8 @@ module RateCard
|
|
|
24
24
|
|
|
25
25
|
# No :token stage — see TokenPrompt for why it cannot live in the loop.
|
|
26
26
|
STAGES = %i[
|
|
27
|
-
loading rate_mode carrier rural rural_surcharges services zones unit weights
|
|
28
|
-
package_type rate_keys confirm fetching
|
|
27
|
+
loading rate_mode carrier rural rural_surcharges services country gls_origin zones unit weights
|
|
28
|
+
package_type rate_keys add_another confirm fetching
|
|
29
29
|
].freeze
|
|
30
30
|
|
|
31
31
|
# #spec/#grid hold the most recently finished pass, for every caller that
|
|
@@ -49,7 +49,7 @@ module RateCard
|
|
|
49
49
|
@field = nil
|
|
50
50
|
@cancelled = false
|
|
51
51
|
@error = nil
|
|
52
|
-
@spinner = Bubbles::Spinner.new
|
|
52
|
+
@spinner = Bubbles::Spinner.new(style: Lipgloss::Style.new.foreground(Theme::ACCENT))
|
|
53
53
|
@progress = Bubbles::Progress.new(width: 32)
|
|
54
54
|
@completed = 0
|
|
55
55
|
@failed = 0
|
|
@@ -58,6 +58,11 @@ module RateCard
|
|
|
58
58
|
@failure_sparkline.style = Lipgloss::Style.new.foreground(Theme::WARNING)
|
|
59
59
|
@log = []
|
|
60
60
|
@results = []
|
|
61
|
+
@queued_specs = []
|
|
62
|
+
@queued_scenarios = []
|
|
63
|
+
@scenario = 1
|
|
64
|
+
@fresh_scenario_start = false
|
|
65
|
+
@log << { stage: :scenario_header, line: scenario_header_line(@scenario) }
|
|
61
66
|
end
|
|
62
67
|
|
|
63
68
|
def cancelled? = @cancelled
|
|
@@ -86,8 +91,9 @@ module RateCard
|
|
|
86
91
|
transcript = @log.filter_map { |entry| entry[:line] }
|
|
87
92
|
sections = []
|
|
88
93
|
sections << transcript.join("\n") unless transcript.empty?
|
|
94
|
+
sections << Theme.divider unless transcript.empty?
|
|
89
95
|
sections << stage_view
|
|
90
|
-
"#{sections.compact.join("\n\n")}\n"
|
|
96
|
+
"#{Theme.panel(sections.compact.join("\n\n"))}\n#{Theme.footer(footer_text)}\n"
|
|
91
97
|
end
|
|
92
98
|
|
|
93
99
|
private
|
|
@@ -114,6 +120,7 @@ module RateCard
|
|
|
114
120
|
value = @field.value
|
|
115
121
|
# The recap's own Back row, which is the esc key by another name.
|
|
116
122
|
return retreat if @stage == :confirm && value == :back
|
|
123
|
+
return add_another_chosen(value) if @stage == :add_another
|
|
117
124
|
|
|
118
125
|
record(@stage, value)
|
|
119
126
|
advance
|
|
@@ -135,6 +142,7 @@ module RateCard
|
|
|
135
142
|
def record(stage, value)
|
|
136
143
|
@answers[stage] = value
|
|
137
144
|
@log << { stage: stage, line: answered_line(stage, value) }
|
|
145
|
+
@fresh_scenario_start = false
|
|
138
146
|
end
|
|
139
147
|
|
|
140
148
|
def advance
|
|
@@ -148,6 +156,8 @@ module RateCard
|
|
|
148
156
|
break unless @field.nil?
|
|
149
157
|
end
|
|
150
158
|
nil
|
|
159
|
+
rescue RequestFailed, UnsupportedCarrier => e
|
|
160
|
+
fail_with(e).last
|
|
151
161
|
end
|
|
152
162
|
|
|
153
163
|
# Steps back to the nearest earlier stage that actually asked something,
|
|
@@ -155,6 +165,9 @@ module RateCard
|
|
|
155
165
|
# revisited seeds the field, and every answer after it is forgotten —
|
|
156
166
|
# they were given against a choice that may be about to change.
|
|
157
167
|
def retreat
|
|
168
|
+
return confirm_back if @stage == :confirm
|
|
169
|
+
return cancel_fresh_scenario if @fresh_scenario_start
|
|
170
|
+
|
|
158
171
|
index = STAGES.index(@stage)
|
|
159
172
|
loop do
|
|
160
173
|
index -= 1
|
|
@@ -171,6 +184,80 @@ module RateCard
|
|
|
171
184
|
end
|
|
172
185
|
end
|
|
173
186
|
|
|
187
|
+
# Esc from confirm must not re-run the wizard's forget-forward logic -
|
|
188
|
+
# the scenario just chosen at add_another is already queued and its
|
|
189
|
+
# answers already cleared (#confirm_or_queue), so there is nothing left
|
|
190
|
+
# to forget. It simply reopens the add_another question.
|
|
191
|
+
def confirm_back
|
|
192
|
+
@stage = :add_another
|
|
193
|
+
@field = field_for(:add_another)
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Esc on the very first field of a scenario just started via "Yes, add
|
|
198
|
+
# another" — nothing has been answered yet for it, so there is nothing
|
|
199
|
+
# to walk back through. Treated as if "No" had been chosen instead: the
|
|
200
|
+
# new scenario is abandoned and the queue (already holding the prior
|
|
201
|
+
# scenario, committed by #confirm_or_queue) goes straight to confirm.
|
|
202
|
+
def cancel_fresh_scenario
|
|
203
|
+
@fresh_scenario_start = false
|
|
204
|
+
@log.pop if @log.last && @log.last[:stage] == :scenario_header
|
|
205
|
+
@scenario -= 1
|
|
206
|
+
advance_to_confirm
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# add_another is now asked as soon as every field is filled in, before
|
|
210
|
+
# the run is ever confirmed. Either choice queues the just-answered
|
|
211
|
+
# scenario. Yes clears @answers and jumps back to the wizard's first
|
|
212
|
+
# real question for a fresh scenario; No moves on to the confirm
|
|
213
|
+
# screen, which then asks to run the whole queue.
|
|
214
|
+
def add_another_chosen(value)
|
|
215
|
+
confirm_or_queue
|
|
216
|
+
return advance_to_confirm if value == :no
|
|
217
|
+
|
|
218
|
+
@scenario += 1
|
|
219
|
+
@log << { stage: :scenario_header, line: scenario_header_line(@scenario) }
|
|
220
|
+
@fresh_scenario_start = true
|
|
221
|
+
|
|
222
|
+
@stage = STAGES.first
|
|
223
|
+
loop do
|
|
224
|
+
@stage = STAGES[STAGES.index(@stage) + 1]
|
|
225
|
+
@field = field_for(@stage)
|
|
226
|
+
break unless @field.nil?
|
|
227
|
+
end
|
|
228
|
+
nil
|
|
229
|
+
rescue RequestFailed, UnsupportedCarrier => e
|
|
230
|
+
fail_with(e).last
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def advance_to_confirm
|
|
234
|
+
@stage = :confirm
|
|
235
|
+
@field = field_for(:confirm)
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def scenario_header_line(number)
|
|
240
|
+
"\n#{Theme.title("Scenario #{number}")}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Commits the just-confirmed scenario's specs (today's build_specs,
|
|
244
|
+
# unchanged) to the queue and clears @answers so the next pass through
|
|
245
|
+
# the wizard, or #start_fetch, starts from a blank slate. Also grouped
|
|
246
|
+
# under @queued_scenarios, one entry per wizard pass, so the confirm
|
|
247
|
+
# recap can show each scenario as its own block rather than one flat
|
|
248
|
+
# list of specs.
|
|
249
|
+
def confirm_or_queue
|
|
250
|
+
specs = build_specs
|
|
251
|
+
@queued_specs.concat(specs)
|
|
252
|
+
@queued_scenarios << specs
|
|
253
|
+
@log << { stage: :add_another, line: queued_summary_line(specs) }
|
|
254
|
+
@answers = {}
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def queued_summary_line(specs)
|
|
258
|
+
" #{Theme.ok(Theme::TICK)} queued: #{Theme.bold(specs.first.carrier)} — #{specs.sum(&:call_count)} calls"
|
|
259
|
+
end
|
|
260
|
+
|
|
174
261
|
def forget_from(stage)
|
|
175
262
|
dropped = STAGES[STAGES.index(stage)..]
|
|
176
263
|
dropped.each { |name| @answers.delete(name) }
|
|
@@ -184,22 +271,40 @@ module RateCard
|
|
|
184
271
|
when :rural then rural_field
|
|
185
272
|
when :rural_surcharges then rural_surcharges_field
|
|
186
273
|
when :services then services_field
|
|
274
|
+
when :country then country_field
|
|
275
|
+
when :gls_origin then gls_origin_field
|
|
187
276
|
when :zones then zones_field
|
|
188
277
|
when :unit then unit_field
|
|
189
278
|
when :weights then weights_field
|
|
190
279
|
when :package_type then package_type_field
|
|
191
280
|
when :rate_keys then rate_keys_field
|
|
281
|
+
when :add_another then add_another_field
|
|
192
282
|
when :confirm then confirm_field
|
|
193
283
|
end
|
|
194
284
|
end
|
|
195
285
|
|
|
196
286
|
# --------------------------------------------------------------- fields
|
|
197
287
|
|
|
288
|
+
# Reached as soon as every field for the current scenario is filled in.
|
|
289
|
+
# Yes clears @answers and jumps back to the top of the wizard for a
|
|
290
|
+
# fresh scenario; No moves on to the confirm screen for the whole queue.
|
|
291
|
+
def add_another_field
|
|
292
|
+
Fields::Select.new(
|
|
293
|
+
label: 'Add another scenario?',
|
|
294
|
+
choices: [['Yes, add another', :yes],
|
|
295
|
+
["No, continue to run #{@queued_specs.length + 1} scenario(s)", :no]],
|
|
296
|
+
selected: 0
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
|
|
198
300
|
# The last gate before production is touched. It opens on Back, not on
|
|
199
301
|
# Run: the field before it is also confirmed with enter, so a held-down
|
|
200
302
|
# return key must not be able to start 128 production calls by itself.
|
|
303
|
+
# The label names the whole queue once there is more than one scenario
|
|
304
|
+
# in it, so "Run" is never ambiguous about how many cards it fires off.
|
|
201
305
|
def confirm_field
|
|
202
|
-
|
|
306
|
+
label = @queued_scenarios.length > 1 ? "Run all #{@queued_scenarios.length} scenarios?" : 'Run this rate card?'
|
|
307
|
+
Fields::Select.new(label: label,
|
|
203
308
|
choices: [['Run', :run], ['Back', :back]], selected: 1)
|
|
204
309
|
end
|
|
205
310
|
|
|
@@ -210,7 +315,7 @@ module RateCard
|
|
|
210
315
|
def rate_mode_field
|
|
211
316
|
return nil unless @services.any? { |service| service.carrier == 'USPS' }
|
|
212
317
|
|
|
213
|
-
choices = [['Weight', :weight], ['Cubic
|
|
318
|
+
choices = [['Weight', :weight], ['Cubic', :cubic]]
|
|
214
319
|
Fields::Select.new(label: 'Rate by', choices: choices,
|
|
215
320
|
selected: choices.index { |_, mode| mode == @answers[:rate_mode] } || 0)
|
|
216
321
|
end
|
|
@@ -229,7 +334,9 @@ module RateCard
|
|
|
229
334
|
def selectable_carriers
|
|
230
335
|
carriers = Service.group_by_carrier(@services)
|
|
231
336
|
.keys
|
|
232
|
-
.select
|
|
337
|
+
.select do |carrier|
|
|
338
|
+
Constants::Addresses.supported?(carrier) || Constants::Carriers.live_chart?(carrier)
|
|
339
|
+
end
|
|
233
340
|
return carriers & ['USPS'] if @answers[:rate_mode] == :cubic
|
|
234
341
|
|
|
235
342
|
carriers
|
|
@@ -252,8 +359,8 @@ module RateCard
|
|
|
252
359
|
def rural_field
|
|
253
360
|
return nil unless Constants::Carriers.rural_aware?(carrier)
|
|
254
361
|
|
|
255
|
-
choices = [['
|
|
256
|
-
Fields::Select.new(label: '
|
|
362
|
+
choices = [['Standard', false], ['Rural/DAS', true]]
|
|
363
|
+
Fields::Select.new(label: 'Address Type', choices: choices,
|
|
257
364
|
selected: choices.index { |_, v| v == @answers[:rural] } || 0)
|
|
258
365
|
end
|
|
259
366
|
|
|
@@ -274,10 +381,23 @@ module RateCard
|
|
|
274
381
|
)
|
|
275
382
|
end
|
|
276
383
|
|
|
384
|
+
# Only offered for international-aware carriers (USPS today, via
|
|
385
|
+
# USPS_CANADA_ORIGINS/USPS_CANADA_DESTINATION), and dropped whenever
|
|
386
|
+
# rural mode is on - USPS rural DAS is its own fixed domestic chart,
|
|
387
|
+
# not something international mode should combine with.
|
|
388
|
+
def country_field
|
|
389
|
+
return nil unless Constants::Carriers.international_aware?(carrier)
|
|
390
|
+
return nil if @answers[:rural]
|
|
391
|
+
|
|
392
|
+
choices = [['Domestic', nil], ['Canada', 'CA']]
|
|
393
|
+
Fields::Select.new(label: 'Destination', choices: choices,
|
|
394
|
+
selected: choices.index { |_, v| v == @answers[:country] } || 0)
|
|
395
|
+
end
|
|
396
|
+
|
|
277
397
|
def zones_field
|
|
278
398
|
return nil if ups_rural?
|
|
279
399
|
|
|
280
|
-
available =
|
|
400
|
+
available = live_zones_for(carrier)
|
|
281
401
|
full = "#{available.first}-#{available.last}"
|
|
282
402
|
answered = @answers[:zones]
|
|
283
403
|
|
|
@@ -301,6 +421,64 @@ module RateCard
|
|
|
301
421
|
carrier == 'UPS' && @answers[:rural] == true
|
|
302
422
|
end
|
|
303
423
|
|
|
424
|
+
# Only asked for GLS: a fixed-origin carrier whose live zone chart is
|
|
425
|
+
# computed from the shipment's origin postal code rather than an
|
|
426
|
+
# account-configured hub, same reasoning as USPS/UPS/FedEx's shipment
|
|
427
|
+
# origin — but that origin only feeds fetch_zone_addresses' lookup, it
|
|
428
|
+
# is not itself a shipment field.
|
|
429
|
+
def gls_origin_field
|
|
430
|
+
return nil unless carrier == 'GLS'
|
|
431
|
+
|
|
432
|
+
Fields::Text.new(
|
|
433
|
+
label: 'GLS origin postal code',
|
|
434
|
+
default: @answers[:gls_origin] || Constants::Addresses::ORIGIN[:postal_code],
|
|
435
|
+
hint: 'GLS zone is computed from this origin, not your account',
|
|
436
|
+
parse: lambda { |raw|
|
|
437
|
+
raise ArgumentError, 'enter a postal code' if raw.strip.empty?
|
|
438
|
+
|
|
439
|
+
raw.strip
|
|
440
|
+
}
|
|
441
|
+
)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def live_zones_for(carrier)
|
|
445
|
+
return Constants::Addresses::USPS_CANADA_ORIGINS.keys.sort if carrier == 'USPS' && @answers[:country] == 'CA'
|
|
446
|
+
return Constants::Addresses.available_zones(carrier) unless Constants::Carriers.live_chart?(carrier)
|
|
447
|
+
|
|
448
|
+
live_chart(carrier).keys.sort
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Memoized per (carrier, run) — the endpoint gets hit once even though
|
|
452
|
+
# zones_field, and later address_for per selected zone, both need the chart.
|
|
453
|
+
def live_chart(carrier)
|
|
454
|
+
@live_charts ||= {}
|
|
455
|
+
@live_charts[carrier] ||= fetch_live_chart(carrier)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def fetch_live_chart(carrier)
|
|
459
|
+
service = @services.find { |s| s.carrier == carrier }
|
|
460
|
+
raise UnsupportedCarrier, "no service_id found for #{carrier}" unless service
|
|
461
|
+
|
|
462
|
+
client = @client_factory.call(@token)
|
|
463
|
+
body = client.fetch_zone_addresses(service.id, from_postal_code: gls_origin_for(carrier))
|
|
464
|
+
chart = (body['zones'] || {}).transform_keys(&:to_i)
|
|
465
|
+
.transform_values { |address| symbolize(address) }
|
|
466
|
+
if chart.empty?
|
|
467
|
+
raise UnsupportedCarrier,
|
|
468
|
+
"eHub found no real address for any zone for #{carrier} on this account yet"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
chart
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def gls_origin_for(carrier)
|
|
475
|
+
carrier == 'GLS' ? (@answers[:gls_origin] || Constants::Addresses::ORIGIN[:postal_code]) : nil
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def symbolize(hash)
|
|
479
|
+
hash.to_h { |k, v| [k.to_sym, v] }
|
|
480
|
+
end
|
|
481
|
+
|
|
304
482
|
# Weight is fixed per cubic tier, so asking for a display unit is
|
|
305
483
|
# meaningless in cubic mode — decided as :oz (unused) and skipped.
|
|
306
484
|
def unit_field
|
|
@@ -400,7 +578,9 @@ module RateCard
|
|
|
400
578
|
end
|
|
401
579
|
|
|
402
580
|
def with_supported_carrier(services)
|
|
403
|
-
services.select
|
|
581
|
+
services.select do |service|
|
|
582
|
+
Constants::Addresses.supported?(service.carrier) || Constants::Carriers.live_chart?(service.carrier)
|
|
583
|
+
end
|
|
404
584
|
end
|
|
405
585
|
|
|
406
586
|
def unsupported_message(services)
|
|
@@ -425,7 +605,7 @@ module RateCard
|
|
|
425
605
|
# grid:} in #results before the next pass starts. Every other carrier
|
|
426
606
|
# and mode has exactly one pass, same as before this existed.
|
|
427
607
|
def start_fetch
|
|
428
|
-
@pending_specs =
|
|
608
|
+
@pending_specs = @queued_specs.dup
|
|
429
609
|
# Before the first call, so a bad path is not discovered after 128 of them.
|
|
430
610
|
begin
|
|
431
611
|
@pending_specs.each { |spec| CsvWriter.ensure_writable!(spec.output_base) }
|
|
@@ -522,8 +702,10 @@ module RateCard
|
|
|
522
702
|
cubic_tiers: cubic ? @answers[:weights] : [],
|
|
523
703
|
rate_mode: @answers[:rate_mode] || :weight,
|
|
524
704
|
rural: @answers[:rural],
|
|
705
|
+
country: @answers[:country],
|
|
525
706
|
package_type: @answers[:package_type],
|
|
526
707
|
rate_keys: @answers[:rate_keys],
|
|
708
|
+
zone_chart: Constants::Carriers.live_chart?(carrier) ? live_chart(carrier) : nil,
|
|
527
709
|
output_base: @output_base,
|
|
528
710
|
show_table: true,
|
|
529
711
|
started_at: Time.now
|
|
@@ -545,42 +727,78 @@ module RateCard
|
|
|
545
727
|
end
|
|
546
728
|
end
|
|
547
729
|
|
|
548
|
-
#
|
|
549
|
-
#
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
730
|
+
# Loading and fetching have no field to ask a keybinding of — the only
|
|
731
|
+
# live key on either screen is Ctrl-C — so they fall back to that.
|
|
732
|
+
QUIT_BINDING = [Bubbles::Key.binding(keys: ['ctrl+c'], help: ['ctrl+c', 'quit'])].freeze
|
|
733
|
+
|
|
734
|
+
def footer_text
|
|
735
|
+
@field&.keymap_hint || Theme.help_view(QUIT_BINDING)
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Everything every queued scenario will do, gathered in one block. By
|
|
739
|
+
# the time confirm is reached, add_another has already queued the
|
|
740
|
+
# current scenario and cleared @answers (#confirm_or_queue), so this
|
|
741
|
+
# reads from @queued_scenarios/@queued_specs rather than @answers -
|
|
742
|
+
# unlike the single-scenario recap this replaces, it has to summarize
|
|
743
|
+
# everything already queued, not just what was just answered.
|
|
555
744
|
def recap_view
|
|
556
|
-
|
|
557
|
-
|
|
745
|
+
scenarios = @queued_scenarios.map { |specs| scenario_recap_block(specs) }.join("\n\n")
|
|
746
|
+
Lipgloss.join_vertical(Lipgloss::LEFT, scenarios, '', summary_block)
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# The production warning and call total, pulled into their own box so
|
|
750
|
+
# the one fact that actually gates the run — this is about to hit
|
|
751
|
+
# production N times — reads as a distinct decision, not another line
|
|
752
|
+
# buried under the scenario details on its left.
|
|
753
|
+
def summary_block
|
|
754
|
+
calls = @queued_specs.sum(&:call_count)
|
|
755
|
+
cards = @queued_specs.length > 1 ? "#{@queued_specs.length} separate cards" : '1 card'
|
|
756
|
+
lines = [
|
|
757
|
+
Theme.bold('Summary'),
|
|
758
|
+
'',
|
|
759
|
+
"#{Theme.bold(calls.to_s)} calls",
|
|
760
|
+
cards,
|
|
761
|
+
'',
|
|
762
|
+
Theme.danger("#{Theme::ALERT} production")
|
|
763
|
+
]
|
|
764
|
+
Lipgloss::Style.new
|
|
765
|
+
.border(Lipgloss::ROUNDED_BORDER)
|
|
766
|
+
.border_foreground(Theme::DANGER)
|
|
767
|
+
.padding(0, 2)
|
|
768
|
+
.render(lines.join("\n"))
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# One block per queued wizard pass. Almost always one spec; more than
|
|
772
|
+
# one only for UPS rural mode with several surcharge types checked, in
|
|
773
|
+
# which case every spec in the block shares carrier/services/package/
|
|
774
|
+
# rate_keys and differs only in zones (see #build_specs).
|
|
775
|
+
def scenario_recap_block(specs)
|
|
776
|
+
first = specs.first
|
|
558
777
|
[
|
|
559
|
-
|
|
560
|
-
*specs.map { |spec| " zones #{spec.zone_summary} · #{
|
|
561
|
-
" columns: #{
|
|
562
|
-
" #{Theme.bold(specs.sum(&:call_count).to_s)} rate calls against #{Theme.danger('production')}" \
|
|
563
|
-
"#{specs.length > 1 ? " (#{specs.length} separate cards)" : ''}"
|
|
778
|
+
" #{Theme.bold(first.carrier)}#{spec_rural_recap(first)} · #{first.services.map(&:name).join(', ')}",
|
|
779
|
+
*specs.map { |spec| " zones #{spec.zone_summary} · #{spec_rows_recap(spec)} · #{first.package_type}" },
|
|
780
|
+
" columns: #{first.rate_keys.map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')}"
|
|
564
781
|
].join("\n")
|
|
565
782
|
end
|
|
566
783
|
|
|
567
|
-
def
|
|
568
|
-
return '' unless
|
|
784
|
+
def spec_rural_recap(spec)
|
|
785
|
+
return '' unless spec.rural
|
|
569
786
|
|
|
570
|
-
" (#{rural_label(
|
|
787
|
+
" (#{rural_label(spec.rural)})"
|
|
571
788
|
end
|
|
572
789
|
|
|
573
|
-
def
|
|
574
|
-
if
|
|
575
|
-
"cubic tiers #{RunSpec.compact_range(
|
|
790
|
+
def spec_rows_recap(spec)
|
|
791
|
+
if spec.rate_mode == :cubic
|
|
792
|
+
"cubic tiers #{RunSpec.compact_range(spec.cubic_tiers)}"
|
|
576
793
|
else
|
|
577
|
-
"weights #{RunSpec.compact_range(
|
|
794
|
+
"weights #{RunSpec.compact_range(spec.weights)} #{spec.weight_unit}"
|
|
578
795
|
end
|
|
579
796
|
end
|
|
580
797
|
|
|
581
798
|
def fetch_view
|
|
582
799
|
total = @spec.call_count
|
|
583
800
|
percent = total.zero? ? 0.0 : @completed.to_f / total
|
|
801
|
+
@progress.full_color = progress_color(percent)
|
|
584
802
|
title = @total_passes > 1 ? "fetching rates (pass #{@pass_index}/#{@total_passes})" : 'fetching rates'
|
|
585
803
|
line = " #{@progress.view_as(percent)} #{@completed}/#{total}"
|
|
586
804
|
line += " #{Theme.warning("#{Theme::ALERT} #{@failed} failed")}" if @failed.positive?
|
|
@@ -589,6 +807,16 @@ module RateCard
|
|
|
589
807
|
view
|
|
590
808
|
end
|
|
591
809
|
|
|
810
|
+
# Warning early, accent through the middle stretch, ok once the run is
|
|
811
|
+
# nearly done — so a glance at the bar's colour alone tells you roughly
|
|
812
|
+
# how far along it is, without reading the fraction next to it.
|
|
813
|
+
def progress_color(percent)
|
|
814
|
+
return Theme::OK if percent >= 0.75
|
|
815
|
+
return Theme::ACCENT if percent >= 0.34
|
|
816
|
+
|
|
817
|
+
Theme::WARNING
|
|
818
|
+
end
|
|
819
|
+
|
|
592
820
|
# Only drawn once a failure has happened: a spark line of zeroes for a
|
|
593
821
|
# clean run would be noise, not signal. It stays once shown even if
|
|
594
822
|
# later ticks are all clean — the failure already happened.
|
|
@@ -602,7 +830,8 @@ module RateCard
|
|
|
602
830
|
# what is already visible rather than the first chance to check it.
|
|
603
831
|
def answered_line(stage, value)
|
|
604
832
|
label = { rate_mode: 'rate by', carrier: 'carrier', rural: 'rural / DAS',
|
|
605
|
-
rural_surcharges: 'surcharge types', services: 'services',
|
|
833
|
+
rural_surcharges: 'surcharge types', services: 'services',
|
|
834
|
+
country: 'destination', gls_origin: 'GLS origin', zones: 'zones',
|
|
606
835
|
unit: 'unit', weights: 'weights', package_type: 'package',
|
|
607
836
|
rate_keys: 'columns' }[stage]
|
|
608
837
|
label = 'cubic tiers' if stage == :weights && @answers[:rate_mode] == :cubic
|
|
@@ -618,6 +847,7 @@ module RateCard
|
|
|
618
847
|
when :weights then RunSpec.compact_range(value)
|
|
619
848
|
when :rural then rural_label(value)
|
|
620
849
|
when :rural_surcharges then value.map { |v| v.to_s.upcase }.join(', ')
|
|
850
|
+
when :country then value || 'Domestic'
|
|
621
851
|
when :rate_keys then value.map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')
|
|
622
852
|
else value.to_s
|
|
623
853
|
end
|
|
@@ -15,6 +15,13 @@ module RateCard
|
|
|
15
15
|
class MultiSelect
|
|
16
16
|
WINDOW = 8
|
|
17
17
|
|
|
18
|
+
HELP_BINDINGS = [
|
|
19
|
+
Bubbles::Key.binding(keys: [' '], help: %w[space toggle]),
|
|
20
|
+
Bubbles::Key.binding(keys: ['a'], help: %w[a all]),
|
|
21
|
+
Bubbles::Key.binding(keys: ['enter'], help: ['enter', 'confirm']),
|
|
22
|
+
Bubbles::Key.binding(keys: ['esc'], help: ['esc', 'back'])
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
18
25
|
attr_reader :label, :error
|
|
19
26
|
|
|
20
27
|
# choices: Array<[display String, value]>
|
|
@@ -30,6 +37,13 @@ module RateCard
|
|
|
30
37
|
|
|
31
38
|
def done? = @done
|
|
32
39
|
|
|
40
|
+
# Pulled out of #view so App can draw it in a persistent footer bar
|
|
41
|
+
# instead of repeating it under every single field.
|
|
42
|
+
def keymap_hint
|
|
43
|
+
counter = Theme.muted("#{@checked.length}/#{@choices.length} selected")
|
|
44
|
+
"#{Theme.help_view(HELP_BINDINGS)} #{counter}"
|
|
45
|
+
end
|
|
46
|
+
|
|
33
47
|
def value
|
|
34
48
|
@choices.each_with_index
|
|
35
49
|
.select { |_choice, index| @checked.include?(index) }
|
|
@@ -56,7 +70,6 @@ module RateCard
|
|
|
56
70
|
def view
|
|
57
71
|
lines = ["#{Theme.accent(Theme::CURSOR)} #{Theme.bold(@label)}"]
|
|
58
72
|
window.each { |index| lines << choice_line(index) }
|
|
59
|
-
lines << " #{Theme.muted(hint)}"
|
|
60
73
|
lines << " #{Theme.danger(Theme::CROSS)} #{Theme.danger(@error)}" if @error
|
|
61
74
|
lines.join("\n")
|
|
62
75
|
end
|
|
@@ -71,11 +84,6 @@ module RateCard
|
|
|
71
84
|
" #{pointer} #{box} #{text}"
|
|
72
85
|
end
|
|
73
86
|
|
|
74
|
-
def hint
|
|
75
|
-
counter = "#{@checked.length}/#{@choices.length} selected"
|
|
76
|
-
"space toggle · a all · enter confirm · esc back · #{counter}"
|
|
77
|
-
end
|
|
78
|
-
|
|
79
87
|
# The spacebar arrives as KEY_SPACE from some terminals and as a plain
|
|
80
88
|
# ' ' rune from others; KeyMessage#space? only recognises the first, so
|
|
81
89
|
# relying on it alone makes the toggle key dead on half of them.
|
|
@@ -10,6 +10,12 @@ module RateCard
|
|
|
10
10
|
class Select
|
|
11
11
|
WINDOW = 8
|
|
12
12
|
|
|
13
|
+
HELP_BINDINGS = [
|
|
14
|
+
Bubbles::Key.binding(keys: %w[up down k j], help: ['↑↓', 'move']),
|
|
15
|
+
Bubbles::Key.binding(keys: ['enter'], help: ['enter', 'confirm']),
|
|
16
|
+
Bubbles::Key.binding(keys: ['esc'], help: ['esc', 'back'])
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
13
19
|
attr_reader :label
|
|
14
20
|
|
|
15
21
|
# choices: Array<[display String, value]>
|
|
@@ -26,6 +32,15 @@ module RateCard
|
|
|
26
32
|
def done? = @chosen
|
|
27
33
|
def value = @value
|
|
28
34
|
|
|
35
|
+
# Pulled out of #view so App can draw it in a persistent footer bar
|
|
36
|
+
# instead of repeating it under every single field.
|
|
37
|
+
def keymap_hint
|
|
38
|
+
hint = Theme.help_view(HELP_BINDINGS)
|
|
39
|
+
return hint if @choices.length <= WINDOW
|
|
40
|
+
|
|
41
|
+
"#{hint} #{Theme.muted("#{@cursor + 1}/#{@choices.length}")}"
|
|
42
|
+
end
|
|
43
|
+
|
|
29
44
|
def update(message)
|
|
30
45
|
return nil unless message.is_a?(Bubbletea::KeyMessage)
|
|
31
46
|
|
|
@@ -50,7 +65,6 @@ module RateCard
|
|
|
50
65
|
" #{display}"
|
|
51
66
|
end
|
|
52
67
|
end
|
|
53
|
-
lines << " #{Theme.muted(hint)}"
|
|
54
68
|
lines.join("\n")
|
|
55
69
|
end
|
|
56
70
|
|
|
@@ -68,12 +82,6 @@ module RateCard
|
|
|
68
82
|
start = [[@cursor - (WINDOW / 2), 0].max, @choices.length - WINDOW].min
|
|
69
83
|
(start...(start + WINDOW))
|
|
70
84
|
end
|
|
71
|
-
|
|
72
|
-
def hint
|
|
73
|
-
parts = ['↑↓ move', 'enter confirm', 'esc back']
|
|
74
|
-
parts << "#{@cursor + 1}/#{@choices.length}" if @choices.length > WINDOW
|
|
75
|
-
parts.join(' · ')
|
|
76
|
-
end
|
|
77
85
|
end
|
|
78
86
|
end
|
|
79
87
|
end
|
|
@@ -11,6 +11,11 @@ module RateCard
|
|
|
11
11
|
# mistyped zone list or a mangled paste of a JWT costs a keystroke rather
|
|
12
12
|
# than the run — the same guarantee the tty-prompt wizard made.
|
|
13
13
|
class Text
|
|
14
|
+
HELP_BINDINGS = [
|
|
15
|
+
Bubbles::Key.binding(keys: ['enter'], help: ['enter', 'confirm']),
|
|
16
|
+
Bubbles::Key.binding(keys: ['esc'], help: ['esc', 'back'])
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
14
19
|
attr_reader :label, :error
|
|
15
20
|
|
|
16
21
|
# parse: String -> value, raising ArgumentError with a message to show.
|
|
@@ -35,6 +40,10 @@ module RateCard
|
|
|
35
40
|
def done? = !@value.nil?
|
|
36
41
|
def value = @value
|
|
37
42
|
|
|
43
|
+
# Pulled out of #view so App can draw it in a persistent footer bar
|
|
44
|
+
# instead of repeating it under every single field.
|
|
45
|
+
def keymap_hint = Theme.help_view(HELP_BINDINGS)
|
|
46
|
+
|
|
38
47
|
def update(message)
|
|
39
48
|
if message.is_a?(Bubbletea::KeyMessage) && message.enter?
|
|
40
49
|
submit
|
|
@@ -47,7 +56,7 @@ module RateCard
|
|
|
47
56
|
|
|
48
57
|
def view
|
|
49
58
|
lines = ["#{Theme.accent(Theme::CURSOR)} #{Theme.bold(@label)} #{@input.view}"]
|
|
50
|
-
lines << " #{Theme.muted(
|
|
59
|
+
lines << " #{Theme.muted(@hint)}" if @hint
|
|
51
60
|
lines << " #{Theme.danger(Theme::CROSS)} #{Theme.danger(@error)}" if @error
|
|
52
61
|
lines.join("\n")
|
|
53
62
|
end
|
data/lib/rate_card/tui/theme.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'lipgloss'
|
|
4
|
+
require 'bubbles'
|
|
4
5
|
|
|
5
6
|
module RateCard
|
|
6
7
|
module TUI
|
|
@@ -9,11 +10,12 @@ module RateCard
|
|
|
9
10
|
# Pastel/TTY pairing this replaced, where only Pastel was gated and the
|
|
10
11
|
# spinner and bar leaked escapes into a pipe.
|
|
11
12
|
module Theme
|
|
12
|
-
ACCENT
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
ACCENT = Lipgloss::AdaptiveColor.new(dark: '#00D3FF', light: '#0072B5')
|
|
14
|
+
SECONDARY = Lipgloss::AdaptiveColor.new(dark: '#B388FF', light: '#6A3FBF')
|
|
15
|
+
OK = Lipgloss::AdaptiveColor.new(dark: '#2ECC71', light: '#1E8449')
|
|
16
|
+
WARNING = Lipgloss::AdaptiveColor.new(dark: '#F5A623', light: '#B7791F')
|
|
17
|
+
DANGER = Lipgloss::AdaptiveColor.new(dark: '#FF5C5C', light: '#C0392B')
|
|
18
|
+
MUTED = Lipgloss::AdaptiveColor.new(dark: '#8A8A8A', light: '#6B6B6B')
|
|
17
19
|
|
|
18
20
|
CURSOR = '❯'
|
|
19
21
|
CHECKED = '◉'
|
|
@@ -23,6 +25,16 @@ module RateCard
|
|
|
23
25
|
BULLET = '▸'
|
|
24
26
|
ALERT = '⚠'
|
|
25
27
|
|
|
28
|
+
# The canonical Charm keybinding legend (bubbles/help) instead of a
|
|
29
|
+
# hand-built "a · b · c" string — it colours the key apart from its
|
|
30
|
+
# description and truncates to width on its own rather than wrapping
|
|
31
|
+
# mid-word in a narrow terminal.
|
|
32
|
+
HELP = Bubbles::Help.new.tap do |help|
|
|
33
|
+
help.key_style = Lipgloss::Style.new.foreground(ACCENT).bold(true)
|
|
34
|
+
help.desc_style = Lipgloss::Style.new.foreground(MUTED)
|
|
35
|
+
help.separator_style = Lipgloss::Style.new.foreground(MUTED)
|
|
36
|
+
end
|
|
37
|
+
|
|
26
38
|
module_function
|
|
27
39
|
|
|
28
40
|
def style = Lipgloss::Style.new
|
|
@@ -34,8 +46,11 @@ module RateCard
|
|
|
34
46
|
def muted(text) = style.foreground(MUTED).render(text)
|
|
35
47
|
def bold(text) = style.bold(true).render(text)
|
|
36
48
|
|
|
49
|
+
# Section/scenario headings use SECONDARY rather than ACCENT so they
|
|
50
|
+
# read as structure, not as something the cursor is sitting on — ACCENT
|
|
51
|
+
# is reserved for the interactive element the user's eye should track.
|
|
37
52
|
def title(text)
|
|
38
|
-
style.bold(true).foreground(
|
|
53
|
+
style.bold(true).foreground(SECONDARY).render(text)
|
|
39
54
|
end
|
|
40
55
|
|
|
41
56
|
# The run targets production and cannot be pointed elsewhere, so the
|
|
@@ -46,6 +61,35 @@ module RateCard
|
|
|
46
61
|
.padding(0, 1)
|
|
47
62
|
.render("#{bold('eHub Rate Card Builder')}\n#{danger('●')} production · api.ehub.com")
|
|
48
63
|
end
|
|
64
|
+
|
|
65
|
+
# Frames the live wizard body (transcript + current field) as one card
|
|
66
|
+
# instead of loose scrolling text, so every stage reads as part of the
|
|
67
|
+
# same surface rather than a fresh block of terminal output.
|
|
68
|
+
def panel(content)
|
|
69
|
+
style.border(Lipgloss::ROUNDED_BORDER)
|
|
70
|
+
.border_foreground(MUTED)
|
|
71
|
+
.padding(1, 2)
|
|
72
|
+
.render(content)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A quiet rule between the answered-questions transcript and whatever
|
|
76
|
+
# is being asked right now, so the eye has a fixed place to land on
|
|
77
|
+
# "what's next" without re-reading everything above it.
|
|
78
|
+
def divider(width = 44)
|
|
79
|
+
muted('─' * width)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Renders a row of Bubbles::Key::Binding as the short help legend.
|
|
83
|
+
def help_view(bindings)
|
|
84
|
+
HELP.short_help_view(bindings)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Drawn once, below the panel, instead of every field repeating its own
|
|
88
|
+
# keybinding line — the keys on offer rarely change shape from field to
|
|
89
|
+
# field, so giving them a fixed home lets the eye stop re-reading them.
|
|
90
|
+
def footer(text)
|
|
91
|
+
" #{text}"
|
|
92
|
+
end
|
|
49
93
|
end
|
|
50
94
|
end
|
|
51
95
|
end
|
data/lib/rate_card/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rate-card
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- eHub
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|