hln_ice 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad2a04435e36bc2e843e01c674c067f8d552f7b528e95e5540b19cbb11a06ff4
4
- data.tar.gz: c5a5769cbf8c2d41e7074812a1ff83e0caf489a9a31d3bf4f2486a2160674118
3
+ metadata.gz: 963f6531ca9e78e0f66d8ede2c7b7e34cc57676726ff7fe8069ceeec03a5b304
4
+ data.tar.gz: b164c7a89a955be98cca09d1f4d685166702549bcfec82e1c8e0e6502c359206
5
5
  SHA512:
6
- metadata.gz: 0ea75c7a3a922fb8fb76388f3d5d3ae568f324afe744861cc79b74c2eb0d83fbcdbed60ff2839391dd0823ba36cb197101494c147cd690036013ad46ea9ffc54
7
- data.tar.gz: e66a9cd04e3b62d2e47731311dbc1ddc1f57f5a52259f81cc5d3a334fd8f82826ac53ba6e75c3f76630b92b8d54761ae5236127162b2893ec177c7c1469c9f00
6
+ metadata.gz: 87e3e121398de525f6649c3852694f6a64dbc7a6e49c5cc16e3f88ec7e060686b29b68b8e1bd8083fa05cb35874be8412531dd06411b5620522ca3b40b5aafff
7
+ data.tar.gz: c9b81008070ad31a93db22e27805db8d355e8f066fb7a45d25e5d778f75385cbe54f78dd6c2baed8f0623ad35a91a8c95dc8c52ddd838e38b69071c6a99c691b
data/.rubocop.yml CHANGED
@@ -1,5 +1,8 @@
1
+ inherit_from: .rubocop_todo.yml
2
+
1
3
  AllCops:
2
4
  TargetRubyVersion: 3.1
5
+ SuggestExtensions: false
3
6
 
4
7
  Style/StringLiterals:
5
8
  Enabled: true
@@ -11,3 +14,15 @@ Style/StringLiteralsInInterpolation:
11
14
 
12
15
  Layout/LineLength:
13
16
  Max: 120
17
+
18
+ # Methods after `private` are indented one level, Rails style.
19
+ Layout/IndentationConsistency:
20
+ EnforcedStyle: indented_internal_methods
21
+
22
+ # With indented private methods, `private` is followed directly by them.
23
+ Layout/EmptyLinesAroundAccessModifier:
24
+ Enabled: false
25
+
26
+ Metrics/BlockLength:
27
+ Exclude:
28
+ - "spec/**/*"
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,43 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config --no-auto-gen-timestamp`
3
+ # using RuboCop version 1.91.0.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 5
10
+ # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
11
+ Metrics/AbcSize:
12
+ Max: 70
13
+
14
+ # Offense count: 2
15
+ # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
16
+ # AllowedMethods: refine
17
+ Metrics/BlockLength:
18
+ Max: 50
19
+
20
+ # Offense count: 1
21
+ # Configuration parameters: CountComments, CountAsOne.
22
+ Metrics/ClassLength:
23
+ Max: 113
24
+
25
+ # Offense count: 3
26
+ # Configuration parameters: AllowedMethods, AllowedPatterns.
27
+ Metrics/CyclomaticComplexity:
28
+ Max: 15
29
+
30
+ # Offense count: 8
31
+ # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
32
+ Metrics/MethodLength:
33
+ Max: 72
34
+
35
+ # Offense count: 1
36
+ # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
37
+ Metrics/ParameterLists:
38
+ Max: 6
39
+
40
+ # Offense count: 2
41
+ # Configuration parameters: AllowedMethods, AllowedPatterns.
42
+ Metrics/PerceivedComplexity:
43
+ Max: 15
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-10-13
4
+
5
+ - **Fix recommendations getting the wrong status when the ICE server outputs series information** (`ICE_OUTPUTSERIESINFORMATION`). The parser took the first coded observation anywhere in a proposal, which could be the selected series instead of the status. When it was, the recommendation's `status` was a series code (e.g. `HEP_B_ADULT_3_DOSE_SERIES`) and its `reasons` were empty, so `simplified_status` reported that vaccine group as `"overdue"` whatever ICE recommended. ICE orders these blocks differently from one request to the next, so which vaccine groups were affected varied. Upgrade before turning series output on.
6
+ - Read every value from the observation it belongs to, by its `observationFocus` code system and code, instead of by position or with descendant searches. This applies to recommendation status and reasons, schedule authorities, dose evaluation status, and the patient and proposal elements. Responses from servers without series output parse exactly as before.
7
+ - Expose the series ICE selected for each recommendation under `series` (`code`, `name`, and `selection`: `"unambiguous"`, `"best_guess"`, or `"alternative"`), its `season` for seasonal vaccines (`code`, `name`, `starts_on`, and `ends_on`), and `doses_remaining` when the server has `ICE_OUTPUTNUMBEROFDOSESREMAINING` enabled. `doses_remaining` is an Integer for a plain count and ICE's text otherwise (e.g. `"Recurring"`). Each evaluation gets the `series` the dose was evaluated against. All new keys are left out when ICE does not send them.
8
+ - Season `starts_on` and `ends_on` are ISO dates read from the trailing `(MM/DD/YYYY - MM/DD/YYYY)` of ICE's season display name, because ICE sends no structured season dates. Both are `nil` if the name does not end in a valid range, e.g. if ICE changes that format. They are passed through as ICE sends them: an `ends_on` of `2100-01-01` is ICE's open-ended end date, and for influenza ICE may return a default season code and label (e.g. `20152016_INFLUENZA_SEASON`) with the current season's dates. Rely on the dates, not the code.
9
+
10
+ ## [0.4.0] - 2026-10-06
11
+
12
+ - Expose ICE's per-dose evaluations under `evaluations` in the result, with one entry per administered dose and vaccine group it counts toward (so a combination vaccine has several). Each entry has the caller's event id, CVX, administration date, vaccine group, dose number, validity, evaluation status, and reasons. Codes are passed through as ICE returns them. `recommendations` and `simplified_status` are unchanged.
13
+
3
14
  ## [0.3.0] - 2026-09-24
4
15
 
5
16
  - Expose schedule authorities (e.g. `ACIP_CDC`, `AAP`, `AAFP`) on each recommendation under `schedule_authorities` when the ICE server has `outputScheduleAuthorities` enabled. The key is omitted when the server does not return them.
data/README.md CHANGED
@@ -6,7 +6,8 @@ Given a patient's demographics and immunization history, `HlnIce` builds the
6
6
  VMR (Virtual Medical Record) request, calls the ICE evaluation endpoint, and
7
7
  parses the response into:
8
8
 
9
- - a full list of **recommendations** (one per vaccine group, with status, reasons, and administration intervals), and
9
+ - a full list of **recommendations** (one per vaccine group, with status, reasons, administration intervals, and, when the ICE server outputs them, schedule authorities, series, season, and doses remaining),
10
+ - the **evaluations** of each administered dose (per vaccine group, with dose number, validity, and reasons), and
10
11
  - a **simplified status** map keyed by vaccine group — e.g. `{ ipv_opv: "overdue", mmr: "compliant" }`.
11
12
 
12
13
  The gem has no Rails or ActiveSupport dependency; it takes an injected logger
@@ -79,6 +80,7 @@ patient_data = {
79
80
  clinicalStatements: {
80
81
  substanceAdministrationEvents: [
81
82
  {
83
+ id: "1", # echoed back as event_id on evaluations; a UUID is generated if omitted
82
84
  substance: {
83
85
  substanceCode: { code: "10", displayName: "Polio" }
84
86
  },
@@ -113,7 +115,42 @@ On success:
113
115
  schedule_authorities: [
114
116
  { code: "ACIP_CDC", name: "Advisory Committee on Immunization Practices / Centers for Disease Control and Prevention" },
115
117
  { code: "AAP", name: "American Academy of Pediatrics" }
116
- ]
118
+ ],
119
+ # Only present when the ICE server has ICE_OUTPUTSERIESINFORMATION enabled.
120
+ # selection is "unambiguous", "best_guess", or "alternative".
121
+ series: { code: "...", name: "...", selection: "best_guess" }, # e.g. HEP_B_ADULT_3_DOSE_SERIES
122
+ # Only present for seasonal vaccines (e.g. influenza, COVID-19) with series output on
123
+ # starts_on/ends_on are read from the end of ICE's season name, e.g.
124
+ # "2026-2027 COVID-19 Season (08/28/2026 - 01/01/2100)", because ICE sends
125
+ # no structured season dates. They are nil if the name is not in that format.
126
+ # ICE uses an end of 2100-01-01 for an open-ended season. For influenza, ICE
127
+ # may send a default code and label (e.g. 20152016_INFLUENZA_SEASON) with the
128
+ # current season's dates; the dates are the ones to rely on.
129
+ season: {
130
+ code: "COVID_19_AUG_2026_SEASON",
131
+ name: "2026-2027 COVID-19 Season (08/28/2026 - 01/01/2100)",
132
+ starts_on: "2026-08-28",
133
+ ends_on: "2100-01-01"
134
+ },
135
+ # Only present when ICE_OUTPUTNUMBEROFDOSESREMAINING is also enabled. An Integer
136
+ # for a plain count, otherwise ICE's text, e.g. "Recurring".
137
+ doses_remaining: 3
138
+ }
139
+ ],
140
+ # One entry per administered dose and vaccine group it counts toward, so a
141
+ # combination vaccine has several. Codes are passed through from ICE.
142
+ evaluations: [
143
+ {
144
+ event_id: "1",
145
+ cvx: "10",
146
+ administered_on: "2020-03-01",
147
+ vaccine_group: { code: "400", name: "Polio Vaccine Group" },
148
+ dose_number: 1, # nil if ICE omits it
149
+ valid: true,
150
+ status: { code: "VALID", name: "Valid Dose" }, # e.g. VALID, INVALID, ACCEPTED
151
+ reasons: [], # e.g. [{ code: "BELOW_MINIMUM_INTERVAL", name: "Below Minimum Interval" }]
152
+ # The series the dose was evaluated against. Only present with series output on.
153
+ series: { code: "...", name: "...", selection: "unambiguous" } # e.g. MMR_2_DOSE_SERIES
117
154
  }
118
155
  ],
119
156
  simplified_status: { ipv_opv: "overdue" }
@@ -129,6 +166,10 @@ On failure (service error, timeout after retries, or unparseable response):
129
166
 
130
167
  Always branch on `result[:success]` before reading `result[:data]`.
131
168
 
169
+ Optional keys (`intervals`, `schedule_authorities`, `series`, `season`,
170
+ `doses_remaining`) are left out when ICE does not send them, so reading one
171
+ returns `nil`.
172
+
132
173
  ### Simplified status values
133
174
 
134
175
  `simplified_status` maps each recognized vaccine group to one of:
data/hln_ice.gemspec ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/hln_ice/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "hln_ice"
7
+ spec.version = HlnIce::VERSION
8
+ spec.authors = ["Sam Sohn"]
9
+ spec.email = ["sam@primary.health"]
10
+
11
+ spec.summary = "Ruby client for the HLN ICE (Immunization Calculation Engine) OpenCDS service."
12
+ spec.description = "Evaluates a patient's immunization history against the HLN ICE forecasting " \
13
+ "rules and returns both the raw recommendations and a simplified status mapping."
14
+ spec.homepage = "https://github.com/PrimaryDotHealth/hln_ice"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 3.1.0"
17
+
18
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
19
+
20
+ spec.metadata["homepage_uri"] = spec.homepage
21
+ spec.metadata["source_code_uri"] = spec.homepage
22
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
23
+ spec.metadata["rubygems_mfa_required"] = "true"
24
+
25
+ # Specify which files should be added to the gem when it is released.
26
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
27
+ spec.files = Dir.chdir(__dir__) do
28
+ `git ls-files -z`.split("\x0").reject do |f|
29
+ (File.expand_path(f) == __FILE__) ||
30
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
31
+ end
32
+ end
33
+ spec.bindir = "exe"
34
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
35
+ spec.require_paths = ["lib"]
36
+
37
+ spec.add_dependency "base64", "~> 0.2"
38
+ spec.add_dependency "httparty", "~> 0.21"
39
+ spec.add_dependency "nokogiri", "~> 1.15"
40
+
41
+ # For more information and examples about making a new gem, check out our
42
+ # guide at: https://bundler.io/guides/creating_gem.html
43
+ end
@@ -1,12 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "date"
4
3
  require "json"
5
- require "base64"
6
4
  require "logger"
7
- require "securerandom"
8
5
  require "httparty"
9
- require "nokogiri"
10
6
 
11
7
  module HlnIce
12
8
  # Client for the HLN ICE (Immunization Calculation Engine) OpenCDS service.
@@ -14,28 +10,12 @@ module HlnIce
14
10
  # Evaluates a patient's immunization history against the ICE forecasting
15
11
  # rules and returns both the raw recommendations and a simplified status
16
12
  # mapping keyed by vaccine group.
13
+ #
14
+ # All logging happens here: RequestBuilder and ResponseParser never log, so
15
+ # this class is the only place that can write patient data to the logs.
17
16
  class Client
18
- # Mapping from ICE status codes to our status keys
19
- STATUS_MAPPING = {
20
- "CONDITIONAL" => "conditional",
21
- "FUTURE_RECOMMENDED" => "compliant",
22
- "NOT_RECOMMENDED" => "compliant",
23
- "RECOMMENDED" => "overdue",
24
- }.freeze
25
-
26
- # Mapping from ICE vaccine groups to our immunization keys
27
- VACCINE_MAPPING = {
28
- "DTP Vaccine Group" => :dtap_tdap,
29
- "Hep A Vaccine Group" => :hep_a,
30
- "Hep B Vaccine Group" => :hep_b,
31
- "Hib Vaccine Group" => :hib,
32
- "HPV Vaccine Group" => :hpv,
33
- "Meningococcal Vaccine Group" => :mcv4,
34
- "MMR Vaccine Group" => :mmr,
35
- "Pneumococcal Vaccine Group" => :pcv,
36
- "Polio Vaccine Group" => :ipv_opv,
37
- "Varicella Vaccine Group" => :var,
38
- }.freeze
17
+ STATUS_MAPPING = SimplifiedStatus::STATUS_MAPPING
18
+ VACCINE_MAPPING = SimplifiedStatus::VACCINE_MAPPING
39
19
 
40
20
  attr_reader :base_url, :timeout, :max_retries, :retry_delay, :logger, :log_payloads
41
21
 
@@ -62,7 +42,7 @@ module HlnIce
62
42
  )
63
43
 
64
44
  response.success?
65
- rescue => e
45
+ rescue StandardError => e
66
46
  logger.error("ICE service unavailable: #{e.message}")
67
47
  false
68
48
  end
@@ -95,7 +75,7 @@ module HlnIce
95
75
  else
96
76
  handle_error_response(response)
97
77
  end
98
- rescue => e
78
+ rescue StandardError => e
99
79
  retries += 1
100
80
  if retries <= max_retries
101
81
  logger.warn("Retrying ICE request (#{retries}/#{max_retries}): #{e.message}")
@@ -109,157 +89,24 @@ module HlnIce
109
89
  end
110
90
 
111
91
  private
112
- # Plain-Ruby equivalent of ActiveSupport's `blank?`.
113
- def blank?(value)
114
- return true if value.nil?
115
- return value.strip.empty? if value.is_a?(String)
116
- return value.empty? if value.respond_to?(:empty?)
117
-
118
- false
119
- end
120
-
121
- # Plain-Ruby equivalent of ActiveSupport's `present?`.
122
- def present?(value)
123
- !blank?(value)
124
- end
125
-
126
92
  def build_ice_payload(patient_data)
127
- # Extract patient information
128
- patient_id = patient_data[:patient][:id]
129
- date_of_birth = patient_data[:patient][:date_of_birth]
130
- gender = patient_data[:patient][:gender]
131
- gender = "U" if blank?(gender) # Set default to 'U' if blank
93
+ builder = RequestBuilder.new(patient_data)
132
94
 
133
95
  # Log input data for debugging
134
96
  logger.debug("ICE Input Data: #{JSON.pretty_generate(patient_data)}") if log_payloads
135
97
 
136
- # Build the VMR XML
137
- xml_payload = <<~XML
138
- <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
139
- <ns4:cdsInput xmlns:ns2="org.opencds"
140
- xmlns:ns3="org.opencds.vmr.v1_0.schema.vmr"
141
- xmlns:ns4="org.opencds.vmr.v1_0.schema.cdsinput"
142
- xmlns:ns5="org.opencds.vmr.v1_0.schema.cdsoutput">
143
- <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
144
- <cdsContext>
145
- <cdsSystemUserPreferredLanguage code="en" codeSystem="2.16.840.1.113883.6.99" displayName="English"/>
146
- </cdsContext>
147
- <vmrInput>
148
- <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
149
- <patient>
150
- <templateId root="2.16.840.1.113883.3.795.11.2.1.1"/>
151
- <id root="2.16.840.1.113883.3.795.12.100.11" extension="#{patient_id}"/>
152
- <demographics>
153
- <birthTime value="#{format_date_for_ice(date_of_birth)}"/>
154
- <gender code="#{gender}" codeSystem="2.16.840.1.113883.5.1"/>
155
- </demographics>
156
- <clinicalStatements>
157
- <substanceAdministrationEvents>
158
- XML
159
-
160
- # Add immunization records if available
161
- if present?(patient_data[:immunizations])
162
- patient_data[:immunizations].each do |imm|
163
- next unless present?(imm.dig(:clinicalStatements, :substanceAdministrationEvents))
164
-
165
- imm[:clinicalStatements][:substanceAdministrationEvents].each do |event|
166
- next unless present?(event.dig(:substance, :substanceCode))
167
-
168
- substance_code = event[:substance][:substanceCode]
169
-
170
- # Format dates to ICE format (YYYYMMDD)
171
- low_date = format_date_for_ice(event[:administrationTimeInterval][:low])
172
- high_date = format_date_for_ice(event[:administrationTimeInterval][:high])
173
-
174
- xml_payload += <<~XML
175
- <substanceAdministrationEvent>
176
- <templateId root="2.16.840.1.113883.3.795.11.9.1.1"/>
177
- <id root="#{event[:id] || SecureRandom.uuid}"/>
178
- <substanceAdministrationGeneralPurpose code="384810002" codeSystem="2.16.840.1.113883.6.5"/>
179
- <substance>
180
- <id root="#{event[:substance][:id] || SecureRandom.uuid}"/>
181
- <substanceCode code="#{substance_code[:code]}"
182
- codeSystem="2.16.840.1.113883.12.292"
183
- displayName="#{substance_code[:displayName]}"
184
- originalText="#{substance_code[:displayName]}"/>
185
- </substance>
186
- <administrationTimeInterval low="#{low_date}" high="#{high_date}"/>
187
- </substanceAdministrationEvent>
188
- XML
189
- end
190
- end
191
- end
192
-
193
- # Close the XML
194
- xml_payload += <<~XML
195
- </substanceAdministrationEvents>
196
- </clinicalStatements>
197
- </patient>
198
- </vmrInput>
199
- </ns4:cdsInput>
200
- XML
201
-
202
98
  # Log the generated XML for debugging
203
- logger.debug("Generated ICE XML: #{xml_payload}") if log_payloads
99
+ logger.debug("Generated ICE XML: #{builder.xml}") if log_payloads
204
100
 
205
- # Base64 encode the XML
206
- base64_encoded_payload = Base64.strict_encode64(xml_payload)
207
-
208
- # Build the full payload
209
- {
210
- "interactionId" => {
211
- "scopingEntityId" => "org.nyc.cir",
212
- "interactionId" => "#{patient_id}-#{Time.now.to_i}",
213
- "submissionTime" => (Time.now.to_f * 1000).to_i
214
- },
215
- "evaluationRequest" => {
216
- "clientLanguage" => "en",
217
- "clientTimeZoneOffset" => "+0000",
218
- "kmEvaluationRequest" => [
219
- {
220
- "kmId" => {
221
- "scopingEntityId" => "org.nyc.cir",
222
- "businessId" => "ICE",
223
- "version" => "1.0.0"
224
- }
225
- }
226
- ],
227
- "dataRequirementItemData" => [
228
- {
229
- "driId" => {
230
- "containingEntityId" => {
231
- "scopingEntityId" => "org.nyc.cir",
232
- "businessId" => "ICEData",
233
- "version" => "1.0.0"
234
- },
235
- "itemId" => "cdsPayload"
236
- },
237
- "data" => {
238
- "informationModelSSId" => {
239
- "scopingEntityId" => "org.opencds.vmr",
240
- "businessId" => "VMR",
241
- "version" => "1.0"
242
- },
243
- "base64EncodedPayload" => [base64_encoded_payload]
244
- }
245
- }
246
- ]
247
- }
248
- }
101
+ builder.payload
249
102
  end
250
103
 
251
104
  def parse_ice_response(response_body)
252
105
  logger.debug("ICE service response: #{response_body}") if log_payloads
253
106
 
254
- result = JSON.parse(response_body)
255
-
256
- if result.dig("finalKMEvaluationResponse", 0, "kmEvaluationResultData", 0, "data", "base64EncodedPayload", 0)
257
- base64_result = result["finalKMEvaluationResponse"][0]["kmEvaluationResultData"][0]["data"]["base64EncodedPayload"][0]
258
- decoded_result = Base64.decode64(base64_result)
259
-
260
- # Parse the XML response
261
- parsed_result = parse_xml_response(decoded_result)
107
+ parsed_result = ResponseParser.new(response_body).data
262
108
 
109
+ if parsed_result
263
110
  {
264
111
  success: true,
265
112
  data: parsed_result
@@ -271,210 +118,23 @@ module HlnIce
271
118
  error: "No valid response found in ICE output."
272
119
  }
273
120
  end
274
- rescue => e
121
+ rescue StandardError => e
275
122
  logger.error("Error parsing ICE response: #{e.message}")
276
123
  { success: false, error: "Error parsing ICE response: #{e.message}" }
277
124
  end
278
125
 
279
- def parse_xml_response(xml_string)
280
- # Parse the XML
281
- doc = Nokogiri::XML(xml_string)
282
-
283
- # Remove namespaces to simplify parsing
284
- doc.remove_namespaces!
285
-
286
- # Extract patient information
287
- patient = doc.xpath("//patient").first
288
- return { raw_xml: xml_string } unless patient
289
-
290
- patient_id = patient.xpath("./id").first["extension"] rescue nil
291
- birth_time = patient.xpath("./demographics/birthTime").first["value"] rescue nil
292
- gender = patient.xpath("./demographics/gender").first["code"] rescue nil
293
-
294
- # Format birth date if present
295
- birth_date = format_ice_date(birth_time)
296
-
297
- # Extract vaccine recommendations
298
- all_recommendations = []
299
-
300
- doc.xpath("//substanceAdministrationProposal").each do |proposal|
301
- # Get the substance code and name
302
- substance_element = proposal.xpath("./substance/substanceCode").first
303
- next unless substance_element
304
-
305
- vaccine_code = substance_element["code"]
306
- vaccine_name = substance_element["displayName"]
307
- vaccine_code_system = substance_element["codeSystem"]
308
-
309
- # Get the clinical observation result carrying the recommendation status.
310
- # A proposal may contain other observation results (e.g. schedule
311
- # authorities), in any order, so select the one with a status concept.
312
- observation = proposal.xpath(".//observationResult[observationValue/concept]").first
313
- next unless observation
314
-
315
- # Get recommendation status
316
- status_element = observation.xpath(".//observationValue/concept").first
317
-
318
- status_code = status_element["code"]
319
- status_name = status_element["displayName"]
320
-
321
- # Get interpretation reasons
322
- reasons = []
323
- observation.xpath(".//interpretation").each do |interpretation|
324
- reason = {
325
- code: interpretation["code"],
326
- name: interpretation["displayName"]
327
- }
328
-
329
- # Add supplemental text if available
330
- if interpretation["originalText"]
331
- reason[:supplemental_text] = interpretation["originalText"]
332
- end
333
-
334
- reasons << reason
335
- end
336
-
337
- # Get administration time intervals if available
338
- proposed_interval = proposal.xpath("./proposedAdministrationTimeInterval").first
339
- valid_interval = proposal.xpath("./validAdministrationTimeInterval").first
340
-
341
- intervals = {}
342
- if proposed_interval
343
- intervals[:proposed] = {
344
- low: format_ice_date(proposed_interval["low"]),
345
- high: format_ice_date(proposed_interval["high"])
346
- }.compact
347
- end
348
-
349
- if valid_interval
350
- intervals[:valid] = {
351
- low: format_ice_date(valid_interval["low"]),
352
- high: format_ice_date(valid_interval["high"])
353
- }.compact
354
- end
355
-
356
- # Build the recommendation object
357
- recommendation = {
358
- vaccine: {
359
- code: vaccine_code,
360
- code_system: vaccine_code_system,
361
- name: vaccine_name
362
- },
363
- status: {
364
- code: status_code,
365
- name: status_name
366
- },
367
- reasons:
368
- }
369
-
370
- # Only add intervals if they exist
371
- recommendation[:intervals] = intervals if present?(intervals)
372
-
373
- # Only add schedule authorities if the service returned them
374
- # (requires the ICE outputScheduleAuthorities property)
375
- schedule_authorities = parse_schedule_authorities(proposal)
376
- recommendation[:schedule_authorities] = schedule_authorities if present?(schedule_authorities)
377
-
378
- all_recommendations << recommendation
379
- end
380
-
381
- # Create simplified immunization status mapping
382
- simplified_status = {}
383
-
384
- # Process all recommendations to create the simplified mapping
385
- all_recommendations.each do |recommendation|
386
- vaccine_name = recommendation[:vaccine][:name]
387
- status_code = recommendation[:status][:code]
388
-
389
- # Map the vaccine name to our key
390
- vaccine_key = VACCINE_MAPPING[vaccine_name]
391
- next unless vaccine_key # Skip if we don't have a mapping
392
-
393
- # Map the status code to our status
394
- status = STATUS_MAPPING[status_code] || "overdue" # Default to overdue if unknown
395
-
396
- # Special handling for conditional status based on reasons
397
- if status == "conditional"
398
- # Check if any reason indicates medical exemption
399
- has_medical_exemption = recommendation[:reasons].any? { |r| r[:code] == "MEDICAL_EXEMPTION" }
400
- if has_medical_exemption
401
- status = "medically_exempt"
402
- end
403
- end
404
-
405
- # Store in our simplified mapping
406
- simplified_status[vaccine_key] = status
407
- end
408
-
409
- # Build the final result structure
410
- {
411
- raw_xml: xml_string,
412
- patient: {
413
- id: patient_id,
414
- birth_date: birth_date || birth_time,
415
- gender:
416
- }.compact,
417
- recommendations: all_recommendations, # Keep the original flat list for backward compatibility
418
- simplified_status: # Add the simplified mapping
419
- }
420
- end
421
-
422
- # Extract the schedule authorities (e.g. ACIP_CDC, AAP) for a proposal.
423
- # Returns an empty array when the service did not include them.
424
- def parse_schedule_authorities(proposal)
425
- authorities = proposal.xpath(
426
- ".//observationResult[observationFocus/@code='ICE_VACCINE_GROUP_SCHEDULE_AUTHORITIES']/interpretation"
427
- )
428
-
429
- authorities.map do |interpretation|
430
- {
431
- code: interpretation["code"],
432
- name: interpretation["displayName"]
433
- }
434
- end
435
- end
436
-
437
126
  def handle_error_response(response)
438
127
  error_message = "ICE service error: #{response.code}"
439
128
 
440
129
  begin
441
130
  error_details = JSON.parse(response.body)
442
- error_message += " - #{error_details['message'] || error_details['error'] || response.body}"
443
- rescue
131
+ error_message += " - #{error_details["message"] || error_details["error"] || response.body}"
132
+ rescue StandardError
444
133
  error_message += " - #{response.body}"
445
134
  end
446
135
 
447
136
  logger.error(error_message)
448
137
  { success: false, error: error_message }
449
138
  end
450
-
451
- # Helper method to format ICE dates to standard format
452
- def format_ice_date(ice_date)
453
- return nil unless ice_date
454
-
455
- # ICE dates are in format: YYYYMMDDHHMMSS.000+0000
456
- if ice_date.match(/^(\d{4})(\d{2})(\d{2})/)
457
- year = $1
458
- month = $2
459
- day = $3
460
- "#{year}-#{month}-#{day}"
461
- else
462
- ice_date
463
- end
464
- end
465
-
466
- # Helper method to format dates for ICE (YYYYMMDD)
467
- def format_date_for_ice(date_string)
468
- return nil unless date_string
469
-
470
- begin
471
- date = Date.parse(date_string)
472
- # Format as YYYYMMDD
473
- date.strftime("%Y%m%d")
474
- rescue
475
- # If parsing fails, try to use the original string
476
- date_string
477
- end
478
- end
479
139
  end
480
140
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HlnIce
4
+ # Extracts ICE's evaluation of each administered dose from the vMR patient
5
+ # element. ICE nests one substanceAdministrationEvent per vaccine group under
6
+ # each input event, so a combination vaccine yields one evaluation per group
7
+ # it counts toward.
8
+ class EvaluationsParser
9
+ include Support
10
+
11
+ def initialize(patient)
12
+ @patient = patient
13
+ end
14
+
15
+ def evaluations
16
+ events = patient.xpath("./clinicalStatements/substanceAdministrationEvents/substanceAdministrationEvent")
17
+
18
+ events.flat_map do |event|
19
+ event_id = event.at_xpath("./id")&.[]("root")
20
+ cvx = event.at_xpath("./substance/substanceCode")&.[]("code")
21
+
22
+ event.xpath("./relatedClinicalStatement/substanceAdministrationEvent").filter_map do |component|
23
+ parse_evaluation(component, event_id:, cvx:)
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+ attr_reader :patient
30
+
31
+ # Build the evaluation for one vaccine group from a nested event.
32
+ # Returns nil when the event has no evaluation status observation.
33
+ def parse_evaluation(component, event_id:, cvx:)
34
+ observation = vaccine_group_observation(component)
35
+ status = observation&.at_xpath("./observationValue/concept")
36
+ return unless status
37
+
38
+ focus = observation.at_xpath("./observationFocus")
39
+ dose_number = component.at_xpath("./doseNumber")&.[]("value")
40
+ valid = component.at_xpath("./isValid")&.[]("value")
41
+ series = SeriesParser.new(component).series
42
+
43
+ evaluation = {
44
+ event_id:,
45
+ cvx:,
46
+ administered_on: format_ice_date(component.at_xpath("./administrationTimeInterval")&.[]("low")),
47
+ vaccine_group: { code: focus["code"], name: focus["displayName"] },
48
+ dose_number: dose_number&.to_i,
49
+ valid: valid.nil? ? nil : valid == "true",
50
+ status: { code: status["code"], name: status["displayName"] },
51
+ reasons: observation.xpath("./interpretation").map do |interpretation|
52
+ { code: interpretation["code"], name: interpretation["displayName"] }
53
+ end
54
+ }
55
+ evaluation[:series] = series if series
56
+ evaluation
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "securerandom"
5
+
6
+ module HlnIce
7
+ # Builds the ICE evaluate request for a patient: the vMR XML, and the JSON
8
+ # payload that carries it base64 encoded.
9
+ #
10
+ # Does no logging; the Client decides what may be logged.
11
+ class RequestBuilder
12
+ include Support
13
+
14
+ def initialize(patient_data)
15
+ @patient_data = patient_data
16
+
17
+ # Extract patient information
18
+ @patient_id = patient_data[:patient][:id]
19
+ @date_of_birth = patient_data[:patient][:date_of_birth]
20
+ @gender = patient_data[:patient][:gender]
21
+ @gender = "U" if blank?(@gender) # Set default to 'U' if blank
22
+ end
23
+
24
+ def xml
25
+ @xml ||= build_xml
26
+ end
27
+
28
+ def payload
29
+ # Base64 encode the XML
30
+ base64_encoded_payload = Base64.strict_encode64(xml)
31
+
32
+ # Build the full payload
33
+ {
34
+ "interactionId" => {
35
+ "scopingEntityId" => "org.nyc.cir",
36
+ "interactionId" => "#{patient_id}-#{Time.now.to_i}",
37
+ "submissionTime" => (Time.now.to_f * 1000).to_i
38
+ },
39
+ "evaluationRequest" => {
40
+ "clientLanguage" => "en",
41
+ "clientTimeZoneOffset" => "+0000",
42
+ "kmEvaluationRequest" => [
43
+ {
44
+ "kmId" => {
45
+ "scopingEntityId" => "org.nyc.cir",
46
+ "businessId" => "ICE",
47
+ "version" => "1.0.0"
48
+ }
49
+ }
50
+ ],
51
+ "dataRequirementItemData" => [
52
+ {
53
+ "driId" => {
54
+ "containingEntityId" => {
55
+ "scopingEntityId" => "org.nyc.cir",
56
+ "businessId" => "ICEData",
57
+ "version" => "1.0.0"
58
+ },
59
+ "itemId" => "cdsPayload"
60
+ },
61
+ "data" => {
62
+ "informationModelSSId" => {
63
+ "scopingEntityId" => "org.opencds.vmr",
64
+ "businessId" => "VMR",
65
+ "version" => "1.0"
66
+ },
67
+ "base64EncodedPayload" => [base64_encoded_payload]
68
+ }
69
+ }
70
+ ]
71
+ }
72
+ }
73
+ end
74
+
75
+ private
76
+ attr_reader :patient_data, :patient_id, :date_of_birth, :gender
77
+
78
+ def build_xml
79
+ # Build the VMR XML
80
+ xml_payload = <<~XML
81
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
82
+ <ns4:cdsInput xmlns:ns2="org.opencds"
83
+ xmlns:ns3="org.opencds.vmr.v1_0.schema.vmr"
84
+ xmlns:ns4="org.opencds.vmr.v1_0.schema.cdsinput"
85
+ xmlns:ns5="org.opencds.vmr.v1_0.schema.cdsoutput">
86
+ <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
87
+ <cdsContext>
88
+ <cdsSystemUserPreferredLanguage code="en" codeSystem="2.16.840.1.113883.6.99" displayName="English"/>
89
+ </cdsContext>
90
+ <vmrInput>
91
+ <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
92
+ <patient>
93
+ <templateId root="2.16.840.1.113883.3.795.11.2.1.1"/>
94
+ <id root="2.16.840.1.113883.3.795.12.100.11" extension="#{patient_id}"/>
95
+ <demographics>
96
+ <birthTime value="#{format_date_for_ice(date_of_birth)}"/>
97
+ <gender code="#{gender}" codeSystem="2.16.840.1.113883.5.1"/>
98
+ </demographics>
99
+ <clinicalStatements>
100
+ <substanceAdministrationEvents>
101
+ XML
102
+
103
+ # Add immunization records if available
104
+ if present?(patient_data[:immunizations])
105
+ patient_data[:immunizations].each do |imm|
106
+ next unless present?(imm.dig(:clinicalStatements, :substanceAdministrationEvents))
107
+
108
+ imm[:clinicalStatements][:substanceAdministrationEvents].each do |event|
109
+ next unless present?(event.dig(:substance, :substanceCode))
110
+
111
+ substance_code = event[:substance][:substanceCode]
112
+
113
+ # Format dates to ICE format (YYYYMMDD)
114
+ low_date = format_date_for_ice(event[:administrationTimeInterval][:low])
115
+ high_date = format_date_for_ice(event[:administrationTimeInterval][:high])
116
+
117
+ xml_payload += <<~XML
118
+ <substanceAdministrationEvent>
119
+ <templateId root="2.16.840.1.113883.3.795.11.9.1.1"/>
120
+ <id root="#{event[:id] || SecureRandom.uuid}"/>
121
+ <substanceAdministrationGeneralPurpose code="384810002" codeSystem="2.16.840.1.113883.6.5"/>
122
+ <substance>
123
+ <id root="#{event[:substance][:id] || SecureRandom.uuid}"/>
124
+ <substanceCode code="#{substance_code[:code]}"
125
+ codeSystem="2.16.840.1.113883.12.292"
126
+ displayName="#{substance_code[:displayName]}"
127
+ originalText="#{substance_code[:displayName]}"/>
128
+ </substance>
129
+ <administrationTimeInterval low="#{low_date}" high="#{high_date}"/>
130
+ </substanceAdministrationEvent>
131
+ XML
132
+ end
133
+ end
134
+ end
135
+
136
+ # Close the XML
137
+ xml_payload + <<~XML
138
+ </substanceAdministrationEvents>
139
+ </clinicalStatements>
140
+ </patient>
141
+ </vmrInput>
142
+ </ns4:cdsInput>
143
+ XML
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "nokogiri"
6
+
7
+ module HlnIce
8
+ # Parses the body of an ICE evaluate response into the result data:
9
+ # patient, recommendations, evaluations, and simplified status.
10
+ #
11
+ # Does no logging; the Client decides what may be logged.
12
+ class ResponseParser
13
+ include Support
14
+
15
+ def initialize(response_body)
16
+ @response_body = response_body
17
+ end
18
+
19
+ # Returns nil when the response carries no vMR payload. Raises if the
20
+ # body cannot be parsed.
21
+ def data
22
+ result = JSON.parse(response_body)
23
+
24
+ base64_result = result.dig("finalKMEvaluationResponse", 0, "kmEvaluationResultData", 0, "data",
25
+ "base64EncodedPayload", 0)
26
+ return unless base64_result
27
+
28
+ # Parse the XML response
29
+ parse_xml_response(Base64.decode64(base64_result))
30
+ end
31
+
32
+ private
33
+ attr_reader :response_body
34
+
35
+ def parse_xml_response(xml_string)
36
+ doc = Nokogiri::XML(xml_string)
37
+
38
+ # Remove namespaces to simplify parsing
39
+ doc.remove_namespaces!
40
+
41
+ patient = doc.at_xpath("/cdsOutput/vmrOutput/patient")
42
+ return { raw_xml: xml_string } unless patient
43
+
44
+ recommendations = parse_recommendations(patient)
45
+
46
+ {
47
+ raw_xml: xml_string,
48
+ patient: parse_patient(patient),
49
+ recommendations:,
50
+ evaluations: EvaluationsParser.new(patient).evaluations,
51
+ simplified_status: SimplifiedStatus.new(recommendations).to_h
52
+ }
53
+ end
54
+
55
+ def parse_patient(patient)
56
+ birth_time = patient.at_xpath("./demographics/birthTime")&.[]("value")
57
+
58
+ {
59
+ id: patient.at_xpath("./id")&.[]("extension"),
60
+ birth_date: format_ice_date(birth_time) || birth_time,
61
+ gender: patient.at_xpath("./demographics/gender")&.[]("code")
62
+ }.compact
63
+ end
64
+
65
+ def parse_recommendations(patient)
66
+ proposals = patient.xpath(
67
+ "./clinicalStatements/substanceAdministrationProposals/substanceAdministrationProposal"
68
+ )
69
+
70
+ proposals.filter_map { |proposal| parse_recommendation(proposal) }
71
+ end
72
+
73
+ # Returns nil when the proposal has no vaccine group status observation.
74
+ def parse_recommendation(proposal)
75
+ substance = proposal.at_xpath("./substance/substanceCode")
76
+ observation = vaccine_group_observation(proposal)
77
+ status = observation&.at_xpath("./observationValue/concept")
78
+ return unless substance && status
79
+
80
+ recommendation = {
81
+ vaccine: {
82
+ code: substance["code"],
83
+ code_system: substance["codeSystem"],
84
+ name: substance["displayName"]
85
+ },
86
+ status: {
87
+ code: status["code"],
88
+ name: status["displayName"]
89
+ },
90
+ reasons: parse_reasons(observation)
91
+ }
92
+
93
+ intervals = parse_intervals(proposal)
94
+ recommendation[:intervals] = intervals if present?(intervals)
95
+
96
+ # Only add schedule authorities if the service returned them
97
+ # (requires the ICE outputScheduleAuthorities property)
98
+ schedule_authorities = parse_schedule_authorities(proposal)
99
+ recommendation[:schedule_authorities] = schedule_authorities if present?(schedule_authorities)
100
+
101
+ recommendation.merge(parse_series(proposal))
102
+ end
103
+
104
+ def parse_reasons(observation)
105
+ observation.xpath("./interpretation").map do |interpretation|
106
+ reason = {
107
+ code: interpretation["code"],
108
+ name: interpretation["displayName"]
109
+ }
110
+ reason[:supplemental_text] = interpretation["originalText"] if interpretation["originalText"]
111
+ reason
112
+ end
113
+ end
114
+
115
+ def parse_intervals(proposal)
116
+ {
117
+ proposed: parse_interval(proposal.at_xpath("./proposedAdministrationTimeInterval")),
118
+ valid: parse_interval(proposal.at_xpath("./validAdministrationTimeInterval"))
119
+ }.compact
120
+ end
121
+
122
+ def parse_interval(interval)
123
+ return unless interval
124
+
125
+ {
126
+ low: format_ice_date(interval["low"]),
127
+ high: format_ice_date(interval["high"])
128
+ }.compact
129
+ end
130
+
131
+ # Extract the schedule authorities (e.g. ACIP_CDC, AAP) for a proposal.
132
+ # Returns an empty array when the service did not include them.
133
+ def parse_schedule_authorities(proposal)
134
+ observations = related_observations(proposal, focus_code_system: ICE_OUTPUT_FOCUS_CODE_SYSTEM,
135
+ focus_code: "ICE_VACCINE_GROUP_SCHEDULE_AUTHORITIES")
136
+
137
+ observations.xpath("./interpretation").map do |interpretation|
138
+ {
139
+ code: interpretation["code"],
140
+ name: interpretation["displayName"]
141
+ }
142
+ end
143
+ end
144
+
145
+ # Only the fields ICE sent, so responses without series output parse as
146
+ # before.
147
+ def parse_series(proposal)
148
+ series = SeriesParser.new(proposal)
149
+
150
+ {
151
+ series: series.series,
152
+ season: series.season,
153
+ doses_remaining: series.doses_remaining
154
+ }.compact
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HlnIce
4
+ # Extracts the series ICE selected for display from a recommendation or dose
5
+ # evaluation, along with its season and doses remaining. ICE only sends these
6
+ # when the ICE_OUTPUTSERIESINFORMATION and ICE_OUTPUTNUMBEROFDOSESREMAINING
7
+ # service settings are on; each value is nil when ICE did not send it.
8
+ class SeriesParser
9
+ include Support
10
+
11
+ SERIES_DISPLAY_TYPE_CODE_SYSTEM = "2.16.840.1.113883.3.795.12.100.501"
12
+ SERIES_CODE_SYSTEM = "2.16.840.1.113883.3.795.12.100.10"
13
+ SEASON_CODE_SYSTEM = "2.16.840.1.113883.3.795.12.100.11"
14
+ SEASON_DATE_RANGE = %r{\((\d{2}/\d{2}/\d{4}) - (\d{2}/\d{2}/\d{4})\)\z}
15
+
16
+ # In order of preference, so a selected series wins over an alternative.
17
+ SELECTIONS = {
18
+ "SERIES_DISPLAY_UNAMBIGUOUS" => "unambiguous",
19
+ "SERIES_DISPLAY_BEST_GUESS" => "best_guess",
20
+ "SERIES_DISPLAY_ALTERNATIVE" => "alternative"
21
+ }.freeze
22
+
23
+ def initialize(statement)
24
+ @statement = statement
25
+ end
26
+
27
+ def series
28
+ concept = selected_observation&.at_xpath("./observationValue/concept[@codeSystem='#{SERIES_CODE_SYSTEM}']")
29
+ return unless concept
30
+
31
+ {
32
+ code: concept["code"],
33
+ name: concept["displayName"],
34
+ selection: SELECTIONS.fetch(selection_code(selected_observation))
35
+ }
36
+ end
37
+
38
+ def season
39
+ concept = selected_observation_detail("SEASON", SEASON_CODE_SYSTEM)
40
+ &.at_xpath("./observationValue/concept[@codeSystem='#{SEASON_CODE_SYSTEM}']")
41
+ return unless concept
42
+
43
+ starts_on, ends_on = season_dates(concept["displayName"])
44
+
45
+ {
46
+ code: concept["code"],
47
+ name: concept["displayName"],
48
+ starts_on:,
49
+ ends_on:
50
+ }
51
+ end
52
+
53
+ # An Integer when ICE sends a plain count, otherwise ICE's text as is
54
+ # (e.g. "Recurring").
55
+ def doses_remaining
56
+ value = selected_observation_detail("NUMBER_OF_DOSES_REMAINING", SERIES_CODE_SYSTEM)
57
+ &.at_xpath("./observationValue/text")&.[]("value")
58
+
59
+ value&.match?(/\A\d+\z/) ? value.to_i : value
60
+ end
61
+
62
+ private
63
+ attr_reader :statement
64
+
65
+ # ICE sends no structured season dates, only a trailing
66
+ # "(MM/DD/YYYY - MM/DD/YYYY)" in the season's display name. Returns ISO
67
+ # dates, or nils when the name does not end in a valid range.
68
+ def season_dates(name)
69
+ range = SEASON_DATE_RANGE.match(name.to_s)
70
+ return [nil, nil] unless range
71
+
72
+ range.captures.map { |date| Date.strptime(date, "%m/%d/%Y").iso8601 }
73
+ rescue Date::Error
74
+ [nil, nil]
75
+ end
76
+
77
+ def selected_observation
78
+ return @selected_observation if defined?(@selected_observation)
79
+
80
+ @selected_observation = series_selections.min_by do |observation|
81
+ SELECTIONS.keys.index(selection_code(observation))
82
+ end
83
+ end
84
+
85
+ def series_selections
86
+ options = related_observations(statement, focus_code_system: ICE_OUTPUT_FOCUS_CODE_SYSTEM,
87
+ focus_code: "SERIES_DISPLAY_OPTIONS")
88
+
89
+ selections = options.flat_map do |option|
90
+ related_observations(option, focus_code_system: SERIES_DISPLAY_TYPE_CODE_SYSTEM).to_a
91
+ end
92
+
93
+ selections.select { |observation| SELECTIONS.key?(selection_code(observation)) }
94
+ end
95
+
96
+ def selection_code(observation)
97
+ observation.at_xpath("./observationFocus")["code"]
98
+ end
99
+
100
+ def selected_observation_detail(focus_code, focus_code_system)
101
+ return unless selected_observation
102
+
103
+ related_observations(selected_observation, focus_code_system:, focus_code:).first
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HlnIce
4
+ # Maps parsed recommendations to a simplified status keyed by our
5
+ # immunization keys, e.g. { ipv_opv: "overdue", mmr: "compliant" }.
6
+ class SimplifiedStatus
7
+ # Mapping from ICE status codes to our status keys
8
+ STATUS_MAPPING = {
9
+ "CONDITIONAL" => "conditional",
10
+ "FUTURE_RECOMMENDED" => "compliant",
11
+ "NOT_RECOMMENDED" => "compliant",
12
+ "RECOMMENDED" => "overdue"
13
+ }.freeze
14
+
15
+ # Mapping from ICE vaccine groups to our immunization keys
16
+ VACCINE_MAPPING = {
17
+ "DTP Vaccine Group" => :dtap_tdap,
18
+ "Hep A Vaccine Group" => :hep_a,
19
+ "Hep B Vaccine Group" => :hep_b,
20
+ "Hib Vaccine Group" => :hib,
21
+ "HPV Vaccine Group" => :hpv,
22
+ "Meningococcal Vaccine Group" => :mcv4,
23
+ "MMR Vaccine Group" => :mmr,
24
+ "Pneumococcal Vaccine Group" => :pcv,
25
+ "Polio Vaccine Group" => :ipv_opv,
26
+ "Varicella Vaccine Group" => :var
27
+ }.freeze
28
+
29
+ def initialize(recommendations)
30
+ @recommendations = recommendations
31
+ end
32
+
33
+ def to_h
34
+ # Create simplified immunization status mapping
35
+ simplified_status = {}
36
+
37
+ # Process all recommendations to create the simplified mapping
38
+ recommendations.each do |recommendation|
39
+ vaccine_name = recommendation[:vaccine][:name]
40
+ status_code = recommendation[:status][:code]
41
+
42
+ # Map the vaccine name to our key
43
+ vaccine_key = VACCINE_MAPPING[vaccine_name]
44
+ next unless vaccine_key # Skip if we don't have a mapping
45
+
46
+ # Map the status code to our status
47
+ status = STATUS_MAPPING[status_code] || "overdue" # Default to overdue if unknown
48
+
49
+ # Special handling for conditional status based on reasons
50
+ if status == "conditional"
51
+ # Check if any reason indicates medical exemption
52
+ has_medical_exemption = recommendation[:reasons].any? { |r| r[:code] == "MEDICAL_EXEMPTION" }
53
+ status = "medically_exempt" if has_medical_exemption
54
+ end
55
+
56
+ # Store in our simplified mapping
57
+ simplified_status[vaccine_key] = status
58
+ end
59
+
60
+ simplified_status
61
+ end
62
+
63
+ private
64
+ attr_reader :recommendations
65
+ end
66
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module HlnIce
6
+ # Helpers shared by the request builder and response parsers. Included as
7
+ # private instance methods.
8
+ module Support
9
+ VACCINE_GROUP_CODE_SYSTEM = "2.16.840.1.113883.3.795.12.100.1"
10
+ ICE_OUTPUT_FOCUS_CODE_SYSTEM = "2.16.840.1.113883.3.795.12.100.500"
11
+
12
+ private
13
+ # The observations a vMR statement relates directly to, selected by their
14
+ # observationFocus rather than by position: ICE orders them differently
15
+ # from one request to the next, and nests further observations in them.
16
+ def related_observations(statement, focus_code_system:, focus_code: nil)
17
+ focus = "@codeSystem='#{focus_code_system}'"
18
+ focus += " and @code='#{focus_code}'" if focus_code
19
+
20
+ statement.xpath("./relatedClinicalStatement/observationResult[observationFocus[#{focus}]]")
21
+ end
22
+
23
+ # The observation carrying ICE's status for the vaccine group, e.g. a
24
+ # recommendation or dose evaluation status. ICE sends one per statement.
25
+ def vaccine_group_observation(statement)
26
+ related_observations(statement, focus_code_system: VACCINE_GROUP_CODE_SYSTEM).first
27
+ end
28
+
29
+ # Plain-Ruby equivalent of ActiveSupport's `blank?`.
30
+ def blank?(value)
31
+ return true if value.nil?
32
+ return value.strip.empty? if value.is_a?(String)
33
+ return value.empty? if value.respond_to?(:empty?)
34
+
35
+ false
36
+ end
37
+
38
+ # Plain-Ruby equivalent of ActiveSupport's `present?`.
39
+ def present?(value)
40
+ !blank?(value)
41
+ end
42
+
43
+ # Helper method to format ICE dates to standard format
44
+ def format_ice_date(ice_date)
45
+ return nil unless ice_date
46
+
47
+ # ICE dates are in format: YYYYMMDDHHMMSS.000+0000
48
+ if ice_date.match(/^(\d{4})(\d{2})(\d{2})/)
49
+ year = ::Regexp.last_match(1)
50
+ month = ::Regexp.last_match(2)
51
+ day = ::Regexp.last_match(3)
52
+ "#{year}-#{month}-#{day}"
53
+ else
54
+ ice_date
55
+ end
56
+ end
57
+
58
+ # Helper method to format dates for ICE (YYYYMMDD)
59
+ def format_date_for_ice(date_string)
60
+ return nil unless date_string
61
+
62
+ begin
63
+ date = Date.parse(date_string)
64
+ # Format as YYYYMMDD
65
+ date.strftime("%Y%m%d")
66
+ rescue StandardError
67
+ # If parsing fails, try to use the original string
68
+ date_string
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HlnIce
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/hln_ice.rb CHANGED
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "hln_ice/version"
4
+ require_relative "hln_ice/support"
5
+ require_relative "hln_ice/request_builder"
6
+ require_relative "hln_ice/series_parser"
7
+ require_relative "hln_ice/evaluations_parser"
8
+ require_relative "hln_ice/simplified_status"
9
+ require_relative "hln_ice/response_parser"
4
10
  require_relative "hln_ice/client"
5
11
 
6
12
  module HlnIce
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hln_ice
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Sohn
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-24 00:00:00.000000000 Z
10
+ date: 2026-10-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -61,12 +61,20 @@ extra_rdoc_files: []
61
61
  files:
62
62
  - ".rspec"
63
63
  - ".rubocop.yml"
64
+ - ".rubocop_todo.yml"
64
65
  - CHANGELOG.md
65
66
  - LICENSE.txt
66
67
  - README.md
67
68
  - Rakefile
69
+ - hln_ice.gemspec
68
70
  - lib/hln_ice.rb
69
71
  - lib/hln_ice/client.rb
72
+ - lib/hln_ice/evaluations_parser.rb
73
+ - lib/hln_ice/request_builder.rb
74
+ - lib/hln_ice/response_parser.rb
75
+ - lib/hln_ice/series_parser.rb
76
+ - lib/hln_ice/simplified_status.rb
77
+ - lib/hln_ice/support.rb
70
78
  - lib/hln_ice/version.rb
71
79
  - sig/hln_ice.rbs
72
80
  homepage: https://github.com/PrimaryDotHealth/hln_ice
@@ -77,6 +85,7 @@ metadata:
77
85
  homepage_uri: https://github.com/PrimaryDotHealth/hln_ice
78
86
  source_code_uri: https://github.com/PrimaryDotHealth/hln_ice
79
87
  changelog_uri: https://github.com/PrimaryDotHealth/hln_ice/blob/main/CHANGELOG.md
88
+ rubygems_mfa_required: 'true'
80
89
  rdoc_options: []
81
90
  require_paths:
82
91
  - lib