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.
@@ -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