rate-card 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f117b9526f8fe1f4dbde79da9e35bbc8ff432871c3b9736f0a0699d7542ac189
4
- data.tar.gz: d50336d03593f510d841f4e2e2b7bbe280eff3c5fd2f063664deef532dea0182
3
+ metadata.gz: ccc684b0241775fa979051ffd3b9aeed1b08e9eaf8a5bb40bc8560d8f0e2ab39
4
+ data.tar.gz: e3fe60187c6db2cf746a7adabe90a139da79cd655a2b0445e92060db23db2d35
5
5
  SHA512:
6
- metadata.gz: 6879442c47e5188d2792bd1d002d67e8a36fb4b3e9d3bc0ec9d14652c39854915f972171cba58f04be38cef50d747fb5bf1f9af50b8cbec81360a9e7367796ce
7
- data.tar.gz: b5545e4ee1a80c13833861b1b6301bc2ca2b094aa316bb59b49433cabd4a22a54e9f89eca2e2a0d69427b1c5e7544b7e73a16bc11c491a8f868b1272f3a7e6e5
6
+ metadata.gz: 67bb66aab90c7942ec5cf6d29ce62b23240dd347fdd27c8665d4c1d191c398f97d75ec2b2ddaad989ee21c0645ab5120c00dfa9c2a953d7f176298850aa635bc
7
+ data.tar.gz: 6997aabfddff427e90ecfb38153c3262513d31160af889109da5fe729cfd00b239d63c62137a9f733b47c8d5199bee4e7593b91dc29fefe36291d8b608a584ba
data/exe/rate-card CHANGED
@@ -102,7 +102,13 @@ else
102
102
  exit 1
103
103
  end
104
104
 
105
- spec = app.spec
106
- spec.show_table = options[:show_table]
105
+ # Usually one pass. UPS rural mode with more than one surcharge type checked
106
+ # produces several - each is its own card with its own files, run and
107
+ # reported independently so one failing does not stop the others from
108
+ # writing. The process exit code is the worst of all of them.
109
+ exit_codes = app.results.map do |result|
110
+ result[:spec].show_table = options[:show_table]
111
+ RateCard::Runner.new(spec: result[:spec], grid: result[:grid], ui: ui).run
112
+ end
107
113
 
108
- exit RateCard::Runner.new(spec: spec, grid: app.grid, ui: ui).run
114
+ exit exit_codes.max
@@ -72,6 +72,40 @@ module RateCard
72
72
 
73
73
  BY_CARRIER = { 'USPS' => USPS, 'UPS' => UPS, 'FedEx' => FEDEX, 'DHL' => DHL_ECOMMERCE, 'OSM' => OSM }.freeze
74
74
 
75
+ # Rural/remote-area surcharge test addresses. These aren't zone charts
76
+ # (rural surcharge eligibility is a property of the destination zip on
77
+ # a carrier's rural list, not something for_carrier's zone lookup
78
+ # models), so they live outside BY_CARRIER and are looked up directly
79
+ # by callers that need them.
80
+ #
81
+ # Ported from ../rate_table_builder/constants.rb. USPS_RURAL_DAS zones
82
+ # 4-8 were tagged 'maybe' or unverified in the source file - re-verify
83
+ # against USPS's rural lookup before relying on them for a real card.
84
+ USPS_RURAL_DAS = {
85
+ 0 => { address1: '12078 W 4000 N', city: 'Bluebell', state: 'UT', postal_code: '84007', country: 'US' },
86
+ 1 => { address1: '6074 N 4700 W', city: 'Bear River City', state: 'UT', postal_code: '84301', country: 'US' },
87
+ 2 => { address1: '25670 Buffalo Run', city: 'Moran', state: 'WY', postal_code: '83013', country: 'US' },
88
+ 3 => { address1: '17 Texs Loop', city: 'Alder', state: 'MT', postal_code: '59710', country: 'US' },
89
+ 4 => { address1: '1250 153rd St SE', city: 'Norwich', state: 'ND', postal_code: '58768', country: 'US' },
90
+ 5 => { address1: '103 Stonegate Ct', city: 'Gurdon', state: 'AR', postal_code: '71743', country: 'US' },
91
+ 6 => { address1: '31 Edsel Rd', city: 'Webbville', state: 'KY', postal_code: '41180', country: 'US' },
92
+ 7 => { address1: '295 Old Waterford Road', city: 'Littleton', state: 'NH', postal_code: '03561', country: 'US' },
93
+ 8 => { address1: '77 Main Street', city: 'Mars Hill', state: 'ME', postal_code: '04758', country: 'US' }
94
+ }.freeze
95
+
96
+ # UPS has no per-zone rural chart - just one known address per DAS
97
+ # variant. EDAS = Extended Delivery Area Surcharge, RDAS = Remote
98
+ # Delivery Area Surcharge.
99
+ UPS_EDAS_EXCEPTION = { address1: '149 Pheasant Runn Road', city: 'Goshen', state: 'NH', postal_code: '03752',
100
+ country: 'US' }.freeze
101
+
102
+ UPS_RDAS_EXCEPTION = { address1: '106 Yellow Hammer Rd', city: 'Tyner', state: 'NC', postal_code: '27980',
103
+ country: 'US' }.freeze
104
+
105
+ # Looked up by surcharge-type symbol rather than zone, since UPS rural
106
+ # mode sweeps surcharge types instead of zones — see RunSpec#address_for.
107
+ UPS_RURAL = { edas: UPS_EDAS_EXCEPTION, rdas: UPS_RDAS_EXCEPTION }.freeze
108
+
75
109
  module_function
76
110
 
77
111
  # Raises rather than falling back. The old USPS default meant asking for
@@ -30,6 +30,11 @@ module RateCard
30
30
  'dhl' => 'DHL Express', 'dhl_ecommerce' => 'DHL', 'osm' => 'OSM', 'amazon' => 'Amazon'
31
31
  }.freeze
32
32
 
33
+ # Carriers with a rural/DAS surcharge test address in Constants::Addresses.
34
+ # Independent of zone-chart support - a carrier can have a verified zone
35
+ # chart and no rural chart at all.
36
+ RURAL_AWARE = %w[USPS UPS].freeze
37
+
33
38
  module_function
34
39
 
35
40
  # OTHER is only for a malformed entry with no code at all, so a service is
@@ -44,6 +49,10 @@ module RateCard
44
49
  def display_order
45
50
  DISPLAY_ORDER
46
51
  end
52
+
53
+ def rural_aware?(carrier)
54
+ RURAL_AWARE.include?(carrier.to_s)
55
+ end
47
56
  end
48
57
  end
49
58
  end
@@ -16,7 +16,7 @@ module RateCard
16
16
  class RunSpec < Struct.new(
17
17
  :token, :customer_name, :customer_id, :carrier, :services, :zones,
18
18
  :weight_unit, :weights, :package_type, :rate_keys, :output_base,
19
- :show_table, :started_at, :rate_mode, :cubic_tiers,
19
+ :show_table, :started_at, :rate_mode, :cubic_tiers, :rural,
20
20
  keyword_init: true
21
21
  )
22
22
  # Our rate-key name => the field it arrives as in service_rates. Getting
@@ -44,10 +44,26 @@ module RateCard
44
44
  self.class.compact_range(zones)
45
45
  end
46
46
 
47
+ def zone_label(zone)
48
+ zone.is_a?(Symbol) ? zone.to_s.upcase : "Z#{zone}"
49
+ end
50
+
51
+ # true for USPS with rural mode on, or any UPS surcharge type/combination.
52
+ # nil/false (rural mode off, or a non-rural-aware carrier) is falsy here.
53
+ def rural?
54
+ !!rural
55
+ end
56
+
47
57
  # [1,2,3,5] => "1-3,5". Runs of three or more collapse; a pair stays listed,
48
58
  # since "4,5" is no longer than "4-5" and reads as what the user typed.
59
+ #
60
+ # UPS rural mode sweeps surcharge-type symbols (:edas, :rdas) instead of
61
+ # numbered zones - those aren't a range at all, so they're just listed.
49
62
  def self.compact_range(numbers)
50
- sorted = numbers.to_a.sort.uniq
63
+ array = numbers.to_a
64
+ return array.join(',') if array.any? { |n| n.is_a?(Symbol) }
65
+
66
+ sorted = array.sort.uniq
51
67
  runs = sorted.slice_when { |a, b| b != a + 1 }.to_a
52
68
  runs.map { |run| run.length >= 3 ? "#{run.first}-#{run.last}" : run.join(',') }.join(',')
53
69
  end
@@ -104,7 +120,14 @@ module RateCard
104
120
  end
105
121
  end
106
122
 
123
+ # A Symbol zone (:edas, :rdas) only ever comes from UPS rural mode, so it
124
+ # disambiguates from a real zone number without needing to check carrier
125
+ # or rural separately. USPS rural mode keeps integer zones, just against
126
+ # the rural chart instead of the normal one.
107
127
  def address_for(zone)
128
+ return Constants::Addresses::UPS_RURAL[zone] if zone.is_a?(Symbol)
129
+ return Constants::Addresses::USPS_RURAL_DAS[zone] if carrier == 'USPS' && rural?
130
+
108
131
  Constants::Addresses.for_carrier(carrier)[zone]
109
132
  end
110
133
 
@@ -136,7 +159,19 @@ module RateCard
136
159
  end
137
160
 
138
161
  def run_dir
139
- Pathname.new(output_base).join("#{slug}_#{customer_id}_#{timestamp}")
162
+ Pathname.new(output_base).join("#{slug}_#{customer_id}_#{timestamp}#{rural_suffix}")
163
+ end
164
+
165
+ # UPS rural mode runs one pass per selected surcharge type (see
166
+ # TUI::App#build_specs), so a single spec's zones is exactly one symbol -
167
+ # the suffix names it, so two same-second passes don't collide on
168
+ # run_dir. USPS rural mode sweeps real zones as usual, so it just gets
169
+ # the generic suffix.
170
+ def rural_suffix
171
+ return '' unless rural?
172
+
173
+ surcharge = zones.find { |zone| zone.is_a?(Symbol) }
174
+ surcharge ? "_#{surcharge}" : '_rural'
140
175
  end
141
176
 
142
177
  def validate!
@@ -47,7 +47,7 @@ module RateCard
47
47
  end
48
48
 
49
49
  def render(service, rate_key)
50
- header = [spec.weight_label, *spec.zones.map { |zone| "Z#{zone}" }]
50
+ header = [spec.weight_label, *spec.zones.map { |zone| spec.zone_label(zone) }]
51
51
  body = rows(service, rate_key)
52
52
 
53
53
  Lipgloss::Table.new
@@ -24,11 +24,14 @@ 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 services zones unit weights package_type
28
- rate_keys confirm fetching
27
+ loading rate_mode carrier rural rural_surcharges services zones unit weights
28
+ package_type rate_keys confirm fetching
29
29
  ].freeze
30
30
 
31
- attr_reader :spec, :grid, :error, :notifier
31
+ # #spec/#grid hold the most recently finished pass, for every caller that
32
+ # only ever produced one (every carrier except UPS rural mode with more
33
+ # than one surcharge type checked). #results holds every pass.
34
+ attr_reader :spec, :grid, :error, :notifier, :results
32
35
 
33
36
  # notifier is set by the caller to the Bubbletea::Runner, whose #send is
34
37
  # the only way a worker thread can get a message onto the event loop.
@@ -54,6 +57,7 @@ module RateCard
54
57
  @failure_sparkline = Ntcharts::Sparkline.new(32, 1)
55
58
  @failure_sparkline.style = Lipgloss::Style.new.foreground(Theme::WARNING)
56
59
  @log = []
60
+ @results = []
57
61
  end
58
62
 
59
63
  def cancelled? = @cancelled
@@ -69,7 +73,7 @@ module RateCard
69
73
  when ServicesLoaded then services_loaded(message.services)
70
74
  when LoadFailed then return fail_with(message.error)
71
75
  when ProgressAdvanced then progress_advanced(message)
72
- when FetchFinished then return finish(message.grid)
76
+ when FetchFinished then return fetch_finished(message.grid)
73
77
  when FetchFailed then return fail_with(message.error)
74
78
  else
75
79
  return [self, spinner_or_field(message)]
@@ -175,9 +179,11 @@ module RateCard
175
179
 
176
180
  def field_for(stage)
177
181
  case stage
178
- when :rate_mode then rate_mode_field
179
- when :carrier then carrier_field
180
- when :services then services_field
182
+ when :rate_mode then rate_mode_field
183
+ when :carrier then carrier_field
184
+ when :rural then rural_field
185
+ when :rural_surcharges then rural_surcharges_field
186
+ when :services then services_field
181
187
  when :zones then zones_field
182
188
  when :unit then unit_field
183
189
  when :weights then weights_field
@@ -238,8 +244,40 @@ module RateCard
238
244
  )
239
245
  end
240
246
 
247
+ # Only USPS and UPS have a rural/DAS chart (Addresses::USPS_RURAL_DAS,
248
+ # Addresses::UPS_RURAL); every other carrier is decided :normal (nil)
249
+ # and skips this stage, same as any other single-answer stage. Same
250
+ # Normal/Rural toggle for both carriers - which surcharge type(s) UPS
251
+ # means by "rural" is a separate question, :rural_surcharges below.
252
+ def rural_field
253
+ return nil unless Constants::Carriers.rural_aware?(carrier)
254
+
255
+ choices = [['Normal', false], ['Rural (DAS)', true]]
256
+ Fields::Select.new(label: 'Rural / DAS', choices: choices,
257
+ selected: choices.index { |_, v| v == @answers[:rural] } || 0)
258
+ end
259
+
260
+ # UPS has no per-zone rural chart - each surcharge type is one fixed
261
+ # address, not a zone sweep - so this replaces the zones question for
262
+ # UPS rural mode. Checking both produces two separate rate cards (see
263
+ # #build_specs), one per surcharge type, rather than one card that
264
+ # conflates two different addresses under one "zone" axis.
265
+ def rural_surcharges_field
266
+ return nil unless ups_rural?
267
+
268
+ choices = [['EDAS', :edas], ['RDAS', :rdas]]
269
+ answered = @answers[:rural_surcharges]
270
+ Fields::MultiSelect.new(
271
+ label: 'Surcharge types',
272
+ choices: choices,
273
+ checked: answered ? checked_indexes(choices.map(&:last), answered) : (0...choices.length)
274
+ )
275
+ end
276
+
241
277
  def zones_field
242
- available = Constants::Addresses.available_zones(carrier)
278
+ return nil if ups_rural?
279
+
280
+ available = usps_rural? ? Constants::Addresses::USPS_RURAL_DAS.keys.sort : Constants::Addresses.available_zones(carrier)
243
281
  full = "#{available.first}-#{available.last}"
244
282
  answered = @answers[:zones]
245
283
 
@@ -255,6 +293,14 @@ module RateCard
255
293
  )
256
294
  end
257
295
 
296
+ def usps_rural?
297
+ carrier == 'USPS' && @answers[:rural] == true
298
+ end
299
+
300
+ def ups_rural?
301
+ carrier == 'UPS' && @answers[:rural] == true
302
+ end
303
+
258
304
  # Weight is fixed per cubic tier, so asking for a display unit is
259
305
  # meaningless in cubic mode — decided as :oz (unused) and skipped.
260
306
  def unit_field
@@ -373,18 +419,39 @@ module RateCard
373
419
 
374
420
  # ---------------------------------------------------------------- fetch
375
421
 
422
+ # UPS rural mode with more than one surcharge type checked runs as
423
+ # several independent passes (see #build_specs) - each one calls
424
+ # Grid.build, feeds the same progress bar, and lands its own {spec:,
425
+ # grid:} in #results before the next pass starts. Every other carrier
426
+ # and mode has exactly one pass, same as before this existed.
376
427
  def start_fetch
377
- @spec = build_spec
428
+ @pending_specs = build_specs
378
429
  # Before the first call, so a bad path is not discovered after 128 of them.
379
430
  begin
380
- CsvWriter.ensure_writable!(@spec.output_base)
431
+ @pending_specs.each { |spec| CsvWriter.ensure_writable!(spec.output_base) }
381
432
  rescue OutputNotWritable => e
382
433
  return fail_with(e).last
383
434
  end
384
435
 
436
+ @results = []
437
+ @total_passes = @pending_specs.length
438
+ @pass_index = 0
439
+ next_pass
440
+ end
441
+
442
+ def next_pass
443
+ @spec = @pending_specs.shift
444
+ return Bubbletea.quit if @spec.nil?
445
+
446
+ @pass_index += 1
447
+ @completed = 0
448
+ @failed = 0
449
+ fetch_command(@spec)
450
+ end
451
+
452
+ def fetch_command(spec)
385
453
  client = @client_factory.call(@token)
386
454
  notifier = @notifier
387
- spec = @spec
388
455
  provider = @provider
389
456
 
390
457
  lambda do
@@ -408,9 +475,10 @@ module RateCard
408
475
  @failure_sparkline.push(message.failed)
409
476
  end
410
477
 
411
- def finish(grid)
478
+ def fetch_finished(grid)
412
479
  @grid = grid
413
- [self, Bubbletea.quit]
480
+ @results << { spec: @spec, grid: grid }
481
+ [self, next_pass]
414
482
  end
415
483
 
416
484
  def fail_with(error)
@@ -418,7 +486,23 @@ module RateCard
418
486
  [self, Bubbletea.quit]
419
487
  end
420
488
 
489
+ # One RunSpec per pass. UPS rural mode runs one pass per checked
490
+ # surcharge type (each is a fixed single address, not a zone sweep -
491
+ # see Addresses::UPS_RURAL) rather than folding both into one card's
492
+ # zone axis. Every other carrier/mode is the single pass it always was.
493
+ def build_specs
494
+ if ups_rural?
495
+ (@answers[:rural_surcharges] || []).map { |surcharge| run_spec(zones: [surcharge]) }
496
+ else
497
+ [run_spec(zones: @answers[:zones])]
498
+ end
499
+ end
500
+
421
501
  def build_spec
502
+ build_specs.first
503
+ end
504
+
505
+ def run_spec(zones:)
422
506
  cubic = @answers[:rate_mode] == :cubic
423
507
  RunSpec.new(
424
508
  token: @token,
@@ -426,11 +510,12 @@ module RateCard
426
510
  customer_id: identity[:customer_id],
427
511
  carrier: carrier,
428
512
  services: @answers[:services],
429
- zones: @answers[:zones],
513
+ zones: zones,
430
514
  weight_unit: @answers[:unit] || :oz,
431
515
  weights: cubic ? [] : @answers[:weights],
432
516
  cubic_tiers: cubic ? @answers[:weights] : [],
433
517
  rate_mode: @answers[:rate_mode] || :weight,
518
+ rural: @answers[:rural],
434
519
  package_type: @answers[:package_type],
435
520
  rate_keys: @answers[:rate_keys],
436
521
  output_base: @output_base,
@@ -457,15 +542,28 @@ module RateCard
457
542
  # Everything the run will do, gathered in one block. The answers are also
458
543
  # in the transcript above, but they arrived one at a time over eight
459
544
  # screens; this is the only place they can be read against each other.
545
+ #
546
+ # Built from real RunSpecs (build_specs) rather than raw @answers, so a
547
+ # UPS rural run with both surcharge types checked recaps as the two
548
+ # separate cards it will actually produce, one line each.
460
549
  def recap_view
550
+ specs = build_specs
551
+ carrier_line = " #{Theme.bold(carrier)}#{rural_recap} · #{@answers[:services].map(&:name).join(', ')}"
461
552
  [
462
- " #{Theme.bold(carrier)} · #{@answers[:services].map(&:name).join(', ')}",
463
- " zones #{RunSpec.compact_range(@answers[:zones])} · #{rows_recap} · #{@answers[:package_type]}",
553
+ carrier_line,
554
+ *specs.map { |spec| " zones #{spec.zone_summary} · #{rows_recap} · #{@answers[:package_type]}" },
464
555
  " columns: #{@answers[:rate_keys].map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')}",
465
- " #{Theme.bold(call_count.to_s)} rate calls against #{Theme.danger('production')}"
556
+ " #{Theme.bold(specs.sum(&:call_count).to_s)} rate calls against #{Theme.danger('production')}" \
557
+ "#{specs.length > 1 ? " (#{specs.length} separate cards)" : ''}"
466
558
  ].join("\n")
467
559
  end
468
560
 
561
+ def rural_recap
562
+ return '' unless @answers[:rural]
563
+
564
+ " (#{rural_label(@answers[:rural])})"
565
+ end
566
+
469
567
  def rows_recap
470
568
  if @answers[:rate_mode] == :cubic
471
569
  "cubic tiers #{RunSpec.compact_range(@answers[:weights])}"
@@ -474,16 +572,13 @@ module RateCard
474
572
  end
475
573
  end
476
574
 
477
- def call_count
478
- @answers[:weights].length * @answers[:zones].length
479
- end
480
-
481
575
  def fetch_view
482
576
  total = @spec.call_count
483
577
  percent = total.zero? ? 0.0 : @completed.to_f / total
578
+ title = @total_passes > 1 ? "fetching rates (pass #{@pass_index}/#{@total_passes})" : 'fetching rates'
484
579
  line = " #{@progress.view_as(percent)} #{@completed}/#{total}"
485
580
  line += " #{Theme.warning("#{Theme::ALERT} #{@failed} failed")}" if @failed.positive?
486
- view = "#{Theme.bold(' fetching rates')}\n#{line}"
581
+ view = "#{Theme.bold(" #{title}")}\n#{line}"
487
582
  view += "\n#{failure_sparkline_view}" if @failed.positive?
488
583
  view
489
584
  end
@@ -500,7 +595,8 @@ module RateCard
500
595
  # transcript of the run stays on screen — the recap is then a summary of
501
596
  # what is already visible rather than the first chance to check it.
502
597
  def answered_line(stage, value)
503
- label = { rate_mode: 'rate by', carrier: 'carrier', services: 'services', zones: 'zones',
598
+ label = { rate_mode: 'rate by', carrier: 'carrier', rural: 'rural / DAS',
599
+ rural_surcharges: 'surcharge types', services: 'services', zones: 'zones',
504
600
  unit: 'unit', weights: 'weights', package_type: 'package',
505
601
  rate_keys: 'columns' }[stage]
506
602
  label = 'cubic tiers' if stage == :weights && @answers[:rate_mode] == :cubic
@@ -511,13 +607,19 @@ module RateCard
511
607
 
512
608
  def describe(stage, value)
513
609
  case stage
514
- when :services then value.map(&:name).join(', ')
515
- when :zones then RunSpec.compact_range(value)
516
- when :weights then RunSpec.compact_range(value)
517
- when :rate_keys then value.map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')
610
+ when :services then value.map(&:name).join(', ')
611
+ when :zones then RunSpec.compact_range(value)
612
+ when :weights then RunSpec.compact_range(value)
613
+ when :rural then rural_label(value)
614
+ when :rural_surcharges then value.map { |v| v.to_s.upcase }.join(', ')
615
+ when :rate_keys then value.map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')
518
616
  else value.to_s
519
617
  end
520
618
  end
619
+
620
+ def rural_label(value)
621
+ value ? 'rural (DAS)' : 'normal'
622
+ end
521
623
  end
522
624
  end
523
625
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RateCard
4
- VERSION = '0.1.2'
4
+ VERSION = '0.1.4'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rate-card
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - eHub