hln_ice 0.3.0 → 0.4.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 +4 -0
- data/README.md +17 -1
- data/lib/hln_ice/client.rb +15 -355
- data/lib/hln_ice/evaluations_parser.rb +56 -0
- data/lib/hln_ice/request_builder.rb +146 -0
- data/lib/hln_ice/response_parser.rb +164 -0
- data/lib/hln_ice/simplified_status.rb +66 -0
- data/lib/hln_ice/support.rb +53 -0
- data/lib/hln_ice/version.rb +1 -1
- data/lib/hln_ice.rb +5 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80a72761b4c53974d8784c926ca86d27953488ac8c6e99e9bdb386447b8a3f98
|
|
4
|
+
data.tar.gz: 3952414a0bf97c45cc2a7009c4b16b1a016bb866697821c1bf69c1b60bcc05ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2389561c18f1c9d88f486214bf4bcd5eb6b9b3bc92f08b08fc7d6ac360ccc07360646cece3ddde6c26c5a6a5dddb2026bea28f211ce5f700ace3f1baad4179c8
|
|
7
|
+
data.tar.gz: bd197b89a993c6df99f2fad1d586a3da4ce31715388a541f4679ad96a26c2c9635cdebeeef502ba67808fbbb9427c14338bc9fcd0692ca9a84f9ef242128761c
|
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,9 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-10-06
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
|
|
3
7
|
## [0.3.0] - 2026-09-24
|
|
4
8
|
|
|
5
9
|
- 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),
|
|
9
|
+
- a full list of **recommendations** (one per vaccine group, with status, reasons, and administration intervals),
|
|
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
|
},
|
|
@@ -116,6 +118,20 @@ On success:
|
|
|
116
118
|
]
|
|
117
119
|
}
|
|
118
120
|
],
|
|
121
|
+
# One entry per administered dose and vaccine group it counts toward, so a
|
|
122
|
+
# combination vaccine has several. Codes are passed through from ICE.
|
|
123
|
+
evaluations: [
|
|
124
|
+
{
|
|
125
|
+
event_id: "1",
|
|
126
|
+
cvx: "10",
|
|
127
|
+
administered_on: "2020-03-01",
|
|
128
|
+
vaccine_group: { code: "400", name: "Polio Vaccine Group" },
|
|
129
|
+
dose_number: 1, # nil if ICE omits it
|
|
130
|
+
valid: true,
|
|
131
|
+
status: { code: "VALID", name: "Valid Dose" }, # e.g. VALID, INVALID, ACCEPTED
|
|
132
|
+
reasons: [] # e.g. [{ code: "BELOW_MINIMUM_INTERVAL", name: "Below Minimum Interval" }]
|
|
133
|
+
}
|
|
134
|
+
],
|
|
119
135
|
simplified_status: { ipv_opv: "overdue" }
|
|
120
136
|
}
|
|
121
137
|
}
|
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,56 @@
|
|
|
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.xpath("./id").first&.[]("root")
|
|
20
|
+
cvx = event.xpath("./substance/substanceCode").first&.[]("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 = component.xpath("./relatedClinicalStatement/observationResult[observationValue/concept]").first
|
|
35
|
+
return unless observation
|
|
36
|
+
|
|
37
|
+
focus = observation.xpath("./observationFocus").first
|
|
38
|
+
status = observation.xpath("./observationValue/concept").first
|
|
39
|
+
dose_number = component.xpath("./doseNumber").first&.[]("value")
|
|
40
|
+
valid = component.xpath("./isValid").first&.[]("value")
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
event_id:,
|
|
44
|
+
cvx:,
|
|
45
|
+
administered_on: format_ice_date(component.xpath("./administrationTimeInterval").first&.[]("low")),
|
|
46
|
+
vaccine_group: { code: focus&.[]("code"), name: focus&.[]("displayName") },
|
|
47
|
+
dose_number: dose_number&.to_i,
|
|
48
|
+
valid: valid.nil? ? nil : valid == "true",
|
|
49
|
+
status: { code: status["code"], name: status["displayName"] },
|
|
50
|
+
reasons: observation.xpath("./interpretation").map do |interpretation|
|
|
51
|
+
{ code: interpretation["code"], name: interpretation["displayName"] }
|
|
52
|
+
end
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
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,164 @@
|
|
|
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
|
+
# Parse the XML
|
|
37
|
+
doc = Nokogiri::XML(xml_string)
|
|
38
|
+
|
|
39
|
+
# Remove namespaces to simplify parsing
|
|
40
|
+
doc.remove_namespaces!
|
|
41
|
+
|
|
42
|
+
# Extract patient information
|
|
43
|
+
patient = doc.xpath("//patient").first
|
|
44
|
+
return { raw_xml: xml_string } unless patient
|
|
45
|
+
|
|
46
|
+
patient_id = patient.xpath("./id").first&.[]("extension")
|
|
47
|
+
birth_time = patient.xpath("./demographics/birthTime").first&.[]("value")
|
|
48
|
+
gender = patient.xpath("./demographics/gender").first&.[]("code")
|
|
49
|
+
|
|
50
|
+
# Format birth date if present
|
|
51
|
+
birth_date = format_ice_date(birth_time)
|
|
52
|
+
|
|
53
|
+
# Extract vaccine recommendations
|
|
54
|
+
all_recommendations = []
|
|
55
|
+
|
|
56
|
+
doc.xpath("//substanceAdministrationProposal").each do |proposal|
|
|
57
|
+
# Get the substance code and name
|
|
58
|
+
substance_element = proposal.xpath("./substance/substanceCode").first
|
|
59
|
+
next unless substance_element
|
|
60
|
+
|
|
61
|
+
vaccine_code = substance_element["code"]
|
|
62
|
+
vaccine_name = substance_element["displayName"]
|
|
63
|
+
vaccine_code_system = substance_element["codeSystem"]
|
|
64
|
+
|
|
65
|
+
# Get the clinical observation result carrying the recommendation status.
|
|
66
|
+
# A proposal may contain other observation results (e.g. schedule
|
|
67
|
+
# authorities), in any order, so select the one with a status concept.
|
|
68
|
+
observation = proposal.xpath(".//observationResult[observationValue/concept]").first
|
|
69
|
+
next unless observation
|
|
70
|
+
|
|
71
|
+
# Get recommendation status
|
|
72
|
+
status_element = observation.xpath(".//observationValue/concept").first
|
|
73
|
+
|
|
74
|
+
status_code = status_element["code"]
|
|
75
|
+
status_name = status_element["displayName"]
|
|
76
|
+
|
|
77
|
+
# Get interpretation reasons
|
|
78
|
+
reasons = []
|
|
79
|
+
observation.xpath(".//interpretation").each do |interpretation|
|
|
80
|
+
reason = {
|
|
81
|
+
code: interpretation["code"],
|
|
82
|
+
name: interpretation["displayName"]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Add supplemental text if available
|
|
86
|
+
reason[:supplemental_text] = interpretation["originalText"] if interpretation["originalText"]
|
|
87
|
+
|
|
88
|
+
reasons << reason
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get administration time intervals if available
|
|
92
|
+
proposed_interval = proposal.xpath("./proposedAdministrationTimeInterval").first
|
|
93
|
+
valid_interval = proposal.xpath("./validAdministrationTimeInterval").first
|
|
94
|
+
|
|
95
|
+
intervals = {}
|
|
96
|
+
if proposed_interval
|
|
97
|
+
intervals[:proposed] = {
|
|
98
|
+
low: format_ice_date(proposed_interval["low"]),
|
|
99
|
+
high: format_ice_date(proposed_interval["high"])
|
|
100
|
+
}.compact
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if valid_interval
|
|
104
|
+
intervals[:valid] = {
|
|
105
|
+
low: format_ice_date(valid_interval["low"]),
|
|
106
|
+
high: format_ice_date(valid_interval["high"])
|
|
107
|
+
}.compact
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Build the recommendation object
|
|
111
|
+
recommendation = {
|
|
112
|
+
vaccine: {
|
|
113
|
+
code: vaccine_code,
|
|
114
|
+
code_system: vaccine_code_system,
|
|
115
|
+
name: vaccine_name
|
|
116
|
+
},
|
|
117
|
+
status: {
|
|
118
|
+
code: status_code,
|
|
119
|
+
name: status_name
|
|
120
|
+
},
|
|
121
|
+
reasons:
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Only add intervals if they exist
|
|
125
|
+
recommendation[:intervals] = intervals if present?(intervals)
|
|
126
|
+
|
|
127
|
+
# Only add schedule authorities if the service returned them
|
|
128
|
+
# (requires the ICE outputScheduleAuthorities property)
|
|
129
|
+
schedule_authorities = parse_schedule_authorities(proposal)
|
|
130
|
+
recommendation[:schedule_authorities] = schedule_authorities if present?(schedule_authorities)
|
|
131
|
+
|
|
132
|
+
all_recommendations << recommendation
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Build the final result structure
|
|
136
|
+
{
|
|
137
|
+
raw_xml: xml_string,
|
|
138
|
+
patient: {
|
|
139
|
+
id: patient_id,
|
|
140
|
+
birth_date: birth_date || birth_time,
|
|
141
|
+
gender:
|
|
142
|
+
}.compact,
|
|
143
|
+
recommendations: all_recommendations, # Keep the original flat list for backward compatibility
|
|
144
|
+
evaluations: EvaluationsParser.new(patient).evaluations,
|
|
145
|
+
simplified_status: SimplifiedStatus.new(all_recommendations).to_h
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Extract the schedule authorities (e.g. ACIP_CDC, AAP) for a proposal.
|
|
150
|
+
# Returns an empty array when the service did not include them.
|
|
151
|
+
def parse_schedule_authorities(proposal)
|
|
152
|
+
authorities = proposal.xpath(
|
|
153
|
+
".//observationResult[observationFocus/@code='ICE_VACCINE_GROUP_SCHEDULE_AUTHORITIES']/interpretation"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
authorities.map do |interpretation|
|
|
157
|
+
{
|
|
158
|
+
code: interpretation["code"],
|
|
159
|
+
name: interpretation["displayName"]
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
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,53 @@
|
|
|
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
|
+
private
|
|
10
|
+
# Plain-Ruby equivalent of ActiveSupport's `blank?`.
|
|
11
|
+
def blank?(value)
|
|
12
|
+
return true if value.nil?
|
|
13
|
+
return value.strip.empty? if value.is_a?(String)
|
|
14
|
+
return value.empty? if value.respond_to?(:empty?)
|
|
15
|
+
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Plain-Ruby equivalent of ActiveSupport's `present?`.
|
|
20
|
+
def present?(value)
|
|
21
|
+
!blank?(value)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Helper method to format ICE dates to standard format
|
|
25
|
+
def format_ice_date(ice_date)
|
|
26
|
+
return nil unless ice_date
|
|
27
|
+
|
|
28
|
+
# ICE dates are in format: YYYYMMDDHHMMSS.000+0000
|
|
29
|
+
if ice_date.match(/^(\d{4})(\d{2})(\d{2})/)
|
|
30
|
+
year = ::Regexp.last_match(1)
|
|
31
|
+
month = ::Regexp.last_match(2)
|
|
32
|
+
day = ::Regexp.last_match(3)
|
|
33
|
+
"#{year}-#{month}-#{day}"
|
|
34
|
+
else
|
|
35
|
+
ice_date
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Helper method to format dates for ICE (YYYYMMDD)
|
|
40
|
+
def format_date_for_ice(date_string)
|
|
41
|
+
return nil unless date_string
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
date = Date.parse(date_string)
|
|
45
|
+
# Format as YYYYMMDD
|
|
46
|
+
date.strftime("%Y%m%d")
|
|
47
|
+
rescue StandardError
|
|
48
|
+
# If parsing fails, try to use the original string
|
|
49
|
+
date_string
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/hln_ice/version.rb
CHANGED
data/lib/hln_ice.rb
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
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/evaluations_parser"
|
|
7
|
+
require_relative "hln_ice/simplified_status"
|
|
8
|
+
require_relative "hln_ice/response_parser"
|
|
4
9
|
require_relative "hln_ice/client"
|
|
5
10
|
|
|
6
11
|
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.4.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-07 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: base64
|
|
@@ -61,12 +61,18 @@ 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
|
|
68
69
|
- lib/hln_ice.rb
|
|
69
70
|
- lib/hln_ice/client.rb
|
|
71
|
+
- lib/hln_ice/evaluations_parser.rb
|
|
72
|
+
- lib/hln_ice/request_builder.rb
|
|
73
|
+
- lib/hln_ice/response_parser.rb
|
|
74
|
+
- lib/hln_ice/simplified_status.rb
|
|
75
|
+
- lib/hln_ice/support.rb
|
|
70
76
|
- lib/hln_ice/version.rb
|
|
71
77
|
- sig/hln_ice.rbs
|
|
72
78
|
homepage: https://github.com/PrimaryDotHealth/hln_ice
|
|
@@ -77,6 +83,7 @@ metadata:
|
|
|
77
83
|
homepage_uri: https://github.com/PrimaryDotHealth/hln_ice
|
|
78
84
|
source_code_uri: https://github.com/PrimaryDotHealth/hln_ice
|
|
79
85
|
changelog_uri: https://github.com/PrimaryDotHealth/hln_ice/blob/main/CHANGELOG.md
|
|
86
|
+
rubygems_mfa_required: 'true'
|
|
80
87
|
rdoc_options: []
|
|
81
88
|
require_paths:
|
|
82
89
|
- lib
|