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 +4 -4
- data/.rubocop.yml +15 -0
- data/.rubocop_todo.yml +43 -0
- data/CHANGELOG.md +11 -0
- data/README.md +43 -2
- data/hln_ice.gemspec +43 -0
- data/lib/hln_ice/client.rb +15 -355
- data/lib/hln_ice/evaluations_parser.rb +59 -0
- data/lib/hln_ice/request_builder.rb +146 -0
- data/lib/hln_ice/response_parser.rb +157 -0
- data/lib/hln_ice/series_parser.rb +106 -0
- data/lib/hln_ice/simplified_status.rb +66 -0
- data/lib/hln_ice/support.rb +72 -0
- data/lib/hln_ice/version.rb +1 -1
- data/lib/hln_ice.rb +6 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 963f6531ca9e78e0f66d8ede2c7b7e34cc57676726ff7fe8069ceeec03a5b304
|
|
4
|
+
data.tar.gz: b164c7a89a955be98cca09d1f4d685166702549bcfec82e1c8e0e6502c359206
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
data/lib/hln_ice/client.rb
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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: #{
|
|
99
|
+
logger.debug("Generated ICE XML: #{builder.xml}") if log_payloads
|
|
204
100
|
|
|
205
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
data/lib/hln_ice/version.rb
CHANGED
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.
|
|
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-
|
|
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
|