oddb2xml 2.9.9 → 3.0.1
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/CLAUDE.md +69 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +66 -64
- data/gemset.nix +103 -72
- data/lib/oddb2xml/cli.rb +23 -3
- data/lib/oddb2xml/compressor.rb +1 -3
- data/lib/oddb2xml/fhir_support.rb +752 -0
- data/lib/oddb2xml/options.rb +6 -0
- data/lib/oddb2xml/version.rb +1 -1
- data/lib/oddb2xml.rb +10 -0
- data/oddb2xml.gemspec +8 -8
- data/spec/downloader_spec.rb +1 -1
- metadata +28 -26
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# fhir_support.rb
|
|
4
|
+
# Complete FHIR support module for oddb2xml
|
|
5
|
+
# This file provides all FHIR functionality in one place
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require "date"
|
|
9
|
+
require "ostruct"
|
|
10
|
+
|
|
11
|
+
module Oddb2xml
|
|
12
|
+
# FHIR Downloader - Downloads NDJSON from FOPH/BAG
|
|
13
|
+
class FhirDownloader < Downloader
|
|
14
|
+
include DownloadMethod
|
|
15
|
+
|
|
16
|
+
BASE_URL = "https://epl.bag.admin.ch"
|
|
17
|
+
STATIC_FHIR_PATH = "/static/fhir"
|
|
18
|
+
|
|
19
|
+
def initialize(options = {})
|
|
20
|
+
@options = options
|
|
21
|
+
@url = find_latest_fhir_url
|
|
22
|
+
super(options, @url)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def download
|
|
26
|
+
filename = File.basename(@url)
|
|
27
|
+
file = File.join(WORK_DIR, filename)
|
|
28
|
+
@file2save = File.join(DOWNLOADS, filename)
|
|
29
|
+
|
|
30
|
+
report_download(@url, @file2save)
|
|
31
|
+
|
|
32
|
+
# Check if we should skip download (file exists and is recent)
|
|
33
|
+
if skip_download?
|
|
34
|
+
Oddb2xml.log "FhirDownloader: Skip downloading #{@file2save} (#{format_size(File.size(@file2save))}, less than 24h old)"
|
|
35
|
+
return File.expand_path(@file2save)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
# Download the file
|
|
40
|
+
download_as(file, "w+")
|
|
41
|
+
|
|
42
|
+
# Validate NDJSON format
|
|
43
|
+
if validate_ndjson(@file2save)
|
|
44
|
+
line_count = count_ndjson_lines(@file2save)
|
|
45
|
+
Oddb2xml.log "FhirDownloader: NDJSON validation successful (#{line_count} bundles, #{format_size(File.size(@file2save))})"
|
|
46
|
+
else
|
|
47
|
+
Oddb2xml.log "FhirDownloader: WARNING - NDJSON validation failed!"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
File.expand_path(@file2save)
|
|
51
|
+
rescue Timeout::Error, Errno::ETIMEDOUT
|
|
52
|
+
retrievable? ? retry : raise
|
|
53
|
+
rescue => error
|
|
54
|
+
Oddb2xml.log "FhirDownloader: Error downloading FHIR file: #{error.message}"
|
|
55
|
+
raise
|
|
56
|
+
ensure
|
|
57
|
+
Oddb2xml.download_finished(@file2save, false)
|
|
58
|
+
FileUtils.rm_f(file, verbose: true) if File.exist?(file) && file != @file2save
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def find_latest_fhir_url
|
|
65
|
+
agent = Mechanize.new
|
|
66
|
+
response = agent.get "https://epl.bag.admin.ch/api/sl/public/resources/current"
|
|
67
|
+
resources = JSON.parse(response.body)
|
|
68
|
+
"https://epl.bag.admin.ch/static/" + resources["fhir"]["fileUrl"]
|
|
69
|
+
rescue => e
|
|
70
|
+
Oddb2xml.log "FhirDownloader: Error finding latest URL: #{e.message}"
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def skip_download?
|
|
75
|
+
@options[:skip_download] || (File.exist?(@file2save) && file_age_hours(@file2save) < 24)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def file_age_hours(file)
|
|
79
|
+
((Time.now - File.ctime(file)).to_i / 3600.0).round(1)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def format_size(bytes)
|
|
83
|
+
if bytes < 1024
|
|
84
|
+
"#{bytes} bytes"
|
|
85
|
+
elsif bytes < 1024 * 1024
|
|
86
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
|
87
|
+
else
|
|
88
|
+
"#{(bytes / (1024.0 * 1024)).round(1)} MB"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_ndjson(file)
|
|
93
|
+
# Validate NDJSON format by checking first few lines
|
|
94
|
+
return false unless File.exist?(file)
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
line_count = 0
|
|
98
|
+
valid_count = 0
|
|
99
|
+
error_lines = []
|
|
100
|
+
|
|
101
|
+
File.open(file, "r:utf-8") do |f|
|
|
102
|
+
# Check first 10 lines
|
|
103
|
+
10.times do
|
|
104
|
+
line = f.gets
|
|
105
|
+
break if line.nil?
|
|
106
|
+
|
|
107
|
+
line_count += 1
|
|
108
|
+
next if line.strip.empty?
|
|
109
|
+
|
|
110
|
+
begin
|
|
111
|
+
data = JSON.parse(line)
|
|
112
|
+
# Check if it's a FHIR Bundle
|
|
113
|
+
if data["resourceType"] == "Bundle"
|
|
114
|
+
valid_count += 1
|
|
115
|
+
else
|
|
116
|
+
error_lines << "Line #{line_count}: Not a Bundle (resourceType: #{data["resourceType"]})"
|
|
117
|
+
end
|
|
118
|
+
rescue JSON::ParserError => e
|
|
119
|
+
error_lines << "Line #{line_count}: Invalid JSON - #{e.message}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if error_lines.any?
|
|
125
|
+
error_lines.each { |err| Oddb2xml.log "FhirDownloader: #{err}" }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Valid if we found at least some valid bundles
|
|
129
|
+
valid_count > 0
|
|
130
|
+
rescue => e
|
|
131
|
+
Oddb2xml.log "FhirDownloader: Validation error: #{e.message}"
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def count_ndjson_lines(file)
|
|
137
|
+
# Count non-empty lines in the NDJSON file
|
|
138
|
+
count = 0
|
|
139
|
+
File.open(file, "r") do |f|
|
|
140
|
+
f.each_line do |line|
|
|
141
|
+
count += 1 unless line.strip.empty?
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
count
|
|
145
|
+
rescue => e
|
|
146
|
+
Oddb2xml.log "FhirDownloader: Error counting lines: #{e.message}"
|
|
147
|
+
0
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# FHIR Parser - Parses NDJSON and creates compatible structure
|
|
152
|
+
module FHIR
|
|
153
|
+
# Bundle represents one line in the NDJSON file
|
|
154
|
+
class Bundle
|
|
155
|
+
attr_reader :medicinal_product, :packages, :authorizations, :ingredients
|
|
156
|
+
|
|
157
|
+
def initialize(json_line)
|
|
158
|
+
data = JSON.parse(json_line)
|
|
159
|
+
@entries = data["entry"] || []
|
|
160
|
+
parse_entries
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def parse_entries
|
|
166
|
+
@medicinal_product = nil
|
|
167
|
+
@packages = []
|
|
168
|
+
@authorizations = []
|
|
169
|
+
@ingredients = []
|
|
170
|
+
|
|
171
|
+
@entries.each do |entry|
|
|
172
|
+
resource = entry["resource"]
|
|
173
|
+
case resource["resourceType"]
|
|
174
|
+
when "MedicinalProductDefinition"
|
|
175
|
+
@medicinal_product = MedicinalProduct.new(resource)
|
|
176
|
+
when "PackagedProductDefinition"
|
|
177
|
+
@packages << Package.new(resource)
|
|
178
|
+
when "RegulatedAuthorization"
|
|
179
|
+
@authorizations << Authorization.new(resource)
|
|
180
|
+
when "Ingredient"
|
|
181
|
+
@ingredients << Ingredient.new(resource)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
class MedicinalProduct
|
|
188
|
+
attr_reader :names, :atc_code, :classification, :it_codes
|
|
189
|
+
|
|
190
|
+
def initialize(resource)
|
|
191
|
+
@names = {}
|
|
192
|
+
resource["name"]&.each do |name|
|
|
193
|
+
lang = name.dig("usage", 0, "language", "coding", 0, "code")
|
|
194
|
+
@names[lang] = name["productName"]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Get ATC code (classification[0])
|
|
198
|
+
@atc_code = resource.dig("classification", 0, "coding", 0, "code")
|
|
199
|
+
|
|
200
|
+
# Get product classification (generic/reference) (classification[1])
|
|
201
|
+
@classification = resource.dig("classification", 1, "coding", 0, "code")
|
|
202
|
+
|
|
203
|
+
# Get IT codes (Index Therapeuticus) from classification with correct system
|
|
204
|
+
@it_codes = []
|
|
205
|
+
resource["classification"]&.each do |cls|
|
|
206
|
+
cls["coding"]&.each do |coding|
|
|
207
|
+
if coding["system"]&.include?("index-therapeuticus")
|
|
208
|
+
# Format IT code from "080300" to "08.03.00" or "08.03."
|
|
209
|
+
it_code = format_it_code(coding["code"])
|
|
210
|
+
@it_codes << it_code if it_code
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def name_de
|
|
217
|
+
@names["de-CH"] || @names["de"]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def name_fr
|
|
221
|
+
@names["fr-CH"] || @names["fr"]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def name_it
|
|
225
|
+
@names["it-CH"] || @names["it"]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def it_code
|
|
229
|
+
# Return first IT code (primary), formatted like "08.03.00"
|
|
230
|
+
@it_codes.first
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
def format_it_code(code)
|
|
236
|
+
return nil unless code && code.match?(/^\d{6}$/)
|
|
237
|
+
|
|
238
|
+
# Convert "080300" to "08.03.00"
|
|
239
|
+
"#{code[0..1]}.#{code[2..3]}.#{code[4..5]}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
class Package
|
|
244
|
+
attr_reader :gtin, :description, :swissmedic_no8, :legal_status, :resource_id
|
|
245
|
+
|
|
246
|
+
def initialize(resource)
|
|
247
|
+
@resource_id = resource["id"]
|
|
248
|
+
@gtin = resource.dig("packaging", "identifier", 0, "value")
|
|
249
|
+
@description = resource["description"]
|
|
250
|
+
|
|
251
|
+
# Extract SwissmedicNo8 from GTIN (last 8 digits)
|
|
252
|
+
if @gtin && @gtin.length >= 8
|
|
253
|
+
@swissmedic_no8 = @gtin[-8..-1]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
@legal_status = resource.dig("legalStatusOfSupply", 0, "code", "coding", 0, "code")
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
class Authorization
|
|
261
|
+
attr_reader :identifier, :auth_type, :holder_name, :prices, :foph_dossier_no,
|
|
262
|
+
:status, :listing_status, :subject_reference, :cost_share, :limitations
|
|
263
|
+
|
|
264
|
+
def initialize(resource)
|
|
265
|
+
@identifier = resource.dig("identifier", 0, "value")
|
|
266
|
+
@auth_type = resource.dig("type", "coding", 0, "display")
|
|
267
|
+
@holder_name = resource.dig("contained", 0, "name")
|
|
268
|
+
@subject_reference = resource.dig("subject", 0, "reference")
|
|
269
|
+
@prices = []
|
|
270
|
+
@foph_dossier_no = nil
|
|
271
|
+
@status = nil
|
|
272
|
+
@listing_status = nil
|
|
273
|
+
@cost_share = nil
|
|
274
|
+
@limitations = []
|
|
275
|
+
|
|
276
|
+
# Parse extensions for reimbursement info and prices
|
|
277
|
+
resource["extension"]&.each do |ext|
|
|
278
|
+
if ext["url"]&.include?("reimbursementSL")
|
|
279
|
+
parse_reimbursement_extension(ext)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Parse indications for limitations
|
|
284
|
+
parse_indications(resource["indication"])
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def marketing_authorization?
|
|
288
|
+
@auth_type == "Marketing Authorisation"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def reimbursement_sl?
|
|
292
|
+
@auth_type == "Reimbursement SL"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private
|
|
296
|
+
|
|
297
|
+
def parse_reimbursement_extension(ext)
|
|
298
|
+
ext["extension"]&.each do |sub_ext|
|
|
299
|
+
case sub_ext["url"]
|
|
300
|
+
when "FOPHDossierNumber"
|
|
301
|
+
@foph_dossier_no = sub_ext.dig("valueIdentifier", "value")
|
|
302
|
+
when "status"
|
|
303
|
+
@status = sub_ext.dig("valueCodeableConcept", "coding", 0, "display")
|
|
304
|
+
when "listingStatus"
|
|
305
|
+
@listing_status = sub_ext.dig("valueCodeableConcept", "coding", 0, "display")
|
|
306
|
+
when "costShare"
|
|
307
|
+
@cost_share = sub_ext["valueInteger"]
|
|
308
|
+
else
|
|
309
|
+
# Check if this is a productPrice extension (has nested extensions)
|
|
310
|
+
if sub_ext["url"]&.include?("productPrice")
|
|
311
|
+
@prices << parse_price_extension(sub_ext)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def parse_price_extension(ext)
|
|
318
|
+
price = {}
|
|
319
|
+
ext["extension"]&.each do |sub_ext|
|
|
320
|
+
case sub_ext["url"]
|
|
321
|
+
when "type"
|
|
322
|
+
price[:type] = sub_ext.dig("valueCodeableConcept", "coding", 0, "code")
|
|
323
|
+
when "value"
|
|
324
|
+
price[:value] = sub_ext.dig("valueMoney", "value")
|
|
325
|
+
price[:currency] = sub_ext.dig("valueMoney", "currency")
|
|
326
|
+
when "changeDate"
|
|
327
|
+
price[:change_date] = sub_ext["valueDate"]
|
|
328
|
+
when "changeType"
|
|
329
|
+
price[:change_type] = sub_ext.dig("valueCodeableConcept", "coding", 0, "display")
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
price
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def parse_indications(indications)
|
|
336
|
+
return unless indications
|
|
337
|
+
|
|
338
|
+
indications.each do |indication|
|
|
339
|
+
indication["extension"]&.each do |ext|
|
|
340
|
+
if ext["url"]&.include?("regulatedAuthorization-limitation")
|
|
341
|
+
@limitations << parse_limitation_extension(ext)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def parse_limitation_extension(ext)
|
|
348
|
+
limitation = {}
|
|
349
|
+
ext["extension"]&.each do |sub_ext|
|
|
350
|
+
case sub_ext["url"]
|
|
351
|
+
when "status"
|
|
352
|
+
limitation[:status] = sub_ext.dig("valueCodeableConcept", "coding", 0, "display")
|
|
353
|
+
limitation[:status_code] = sub_ext.dig("valueCodeableConcept", "coding", 0, "code")
|
|
354
|
+
when "statusDate"
|
|
355
|
+
limitation[:status_date] = sub_ext["valueDate"]
|
|
356
|
+
when "limitationText"
|
|
357
|
+
limitation[:text] = sub_ext["valueString"]
|
|
358
|
+
when "period"
|
|
359
|
+
limitation[:start_date] = sub_ext.dig("valuePeriod", "start")
|
|
360
|
+
limitation[:end_date] = sub_ext.dig("valuePeriod", "end")
|
|
361
|
+
when "firstLimitationDate"
|
|
362
|
+
limitation[:first_date] = sub_ext["valueDate"]
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
limitation
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
class Ingredient
|
|
370
|
+
attr_reader :substance_name, :quantity, :unit
|
|
371
|
+
|
|
372
|
+
def initialize(resource)
|
|
373
|
+
@substance_name = resource.dig("substance", "code", "concept", "text")
|
|
374
|
+
strength = resource.dig("substance", "strength", 0)
|
|
375
|
+
@quantity = strength&.dig("presentationQuantity", "value")
|
|
376
|
+
@unit = strength&.dig("presentationQuantity", "unit")
|
|
377
|
+
|
|
378
|
+
# Handle textPresentation for ranges like "340-660"
|
|
379
|
+
@text_presentation = strength&.dig("textPresentation")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def quantity_text
|
|
383
|
+
@text_presentation || (@quantity ? "#{@quantity}" : "")
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Main parser class that provides compatibility with XML parser
|
|
388
|
+
class PreparationsParser
|
|
389
|
+
attr_reader :preparations
|
|
390
|
+
|
|
391
|
+
def initialize(ndjson_file)
|
|
392
|
+
@preparations = []
|
|
393
|
+
parse_file(ndjson_file)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def parse_file(ndjson_file)
|
|
397
|
+
File.foreach(ndjson_file, encoding: 'UTF-8') do |line|
|
|
398
|
+
next if line.strip.empty?
|
|
399
|
+
|
|
400
|
+
bundle = Bundle.new(line)
|
|
401
|
+
next unless bundle.medicinal_product
|
|
402
|
+
|
|
403
|
+
# Create a preparation structure compatible with XML parser output
|
|
404
|
+
prep = create_preparation(bundle)
|
|
405
|
+
@preparations << prep if prep
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
private
|
|
410
|
+
|
|
411
|
+
def create_preparation(bundle)
|
|
412
|
+
mp = bundle.medicinal_product
|
|
413
|
+
|
|
414
|
+
# Create preparation hash structure
|
|
415
|
+
prep = OpenStruct.new
|
|
416
|
+
prep.NameDe = mp.name_de
|
|
417
|
+
prep.NameFr = mp.name_fr
|
|
418
|
+
prep.NameIt = mp.name_it
|
|
419
|
+
prep.AtcCode = mp.atc_code
|
|
420
|
+
prep.OrgGenCode = map_org_gen_code(mp.classification)
|
|
421
|
+
prep.ItCode = mp.it_code # Add IT code
|
|
422
|
+
|
|
423
|
+
# Map packages
|
|
424
|
+
prep.Packs = OpenStruct.new
|
|
425
|
+
prep.Packs.Pack = bundle.packages.map do |pkg|
|
|
426
|
+
pack = OpenStruct.new
|
|
427
|
+
pack.GTIN = pkg.gtin
|
|
428
|
+
pack.SwissmedicNo8 = pkg.swissmedic_no8
|
|
429
|
+
pack.DescriptionDe = pkg.description
|
|
430
|
+
pack.DescriptionFr = pkg.description
|
|
431
|
+
pack.DescriptionIt = pkg.description
|
|
432
|
+
pack.SwissmedicCategory = map_legal_status(pkg.legal_status)
|
|
433
|
+
|
|
434
|
+
# Find prices and additional data for this package
|
|
435
|
+
pack.Prices = create_prices_for_package(bundle, pkg)
|
|
436
|
+
|
|
437
|
+
# Add limitations and cost share
|
|
438
|
+
pack.Limitations = create_limitations_for_package(bundle, pkg)
|
|
439
|
+
pack.CostShare = get_cost_share_for_package(bundle, pkg)
|
|
440
|
+
|
|
441
|
+
pack
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Map ingredients
|
|
445
|
+
prep.Substances = OpenStruct.new
|
|
446
|
+
prep.Substances.Substance = bundle.ingredients.map do |ing|
|
|
447
|
+
substance = OpenStruct.new
|
|
448
|
+
substance.DescriptionLa = ing.substance_name
|
|
449
|
+
substance.Quantity = ing.quantity_text
|
|
450
|
+
substance.QuantityUnit = ing.unit
|
|
451
|
+
substance
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Extract SwissmedicNo5 from the first authorization
|
|
455
|
+
marketing_auth = bundle.authorizations.find(&:marketing_authorization?)
|
|
456
|
+
if marketing_auth
|
|
457
|
+
# SwissmedicNo5 is typically the first 5 digits of the identifier
|
|
458
|
+
prep.SwissmedicNo5 = marketing_auth.identifier&.to_s&.[](0, 5)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
prep
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def create_prices_for_package(bundle, package)
|
|
465
|
+
prices = OpenStruct.new
|
|
466
|
+
|
|
467
|
+
# Find reimbursement authorization for this package by resource ID
|
|
468
|
+
# Match by ending with ID to handle both PackagedProductDefinition and CHIDMPPackagedProductDefinition
|
|
469
|
+
reimbursement = bundle.authorizations.find do |auth|
|
|
470
|
+
auth.reimbursement_sl? && auth.subject_reference&.end_with?(package.resource_id)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
return prices unless reimbursement
|
|
474
|
+
|
|
475
|
+
reimbursement.prices.each do |price|
|
|
476
|
+
if price[:type] == "756002005002"
|
|
477
|
+
exf = OpenStruct.new
|
|
478
|
+
exf.Price = price[:value]
|
|
479
|
+
exf.ValidFromDate = price[:change_date]
|
|
480
|
+
exf.PriceTypeCode = price[:type]
|
|
481
|
+
prices.ExFactoryPrice = exf
|
|
482
|
+
elsif price[:type] == "756002005001"
|
|
483
|
+
pub = OpenStruct.new
|
|
484
|
+
pub.Price = price[:value]
|
|
485
|
+
pub.ValidFromDate = price[:change_date]
|
|
486
|
+
pub.PriceTypeCode = price[:type]
|
|
487
|
+
prices.PublicPrice = pub
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
prices
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def create_limitations_for_package(bundle, package)
|
|
495
|
+
# Find reimbursement authorization for this package
|
|
496
|
+
reimbursement = bundle.authorizations.find do |auth|
|
|
497
|
+
auth.reimbursement_sl? && auth.subject_reference&.end_with?(package.resource_id)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
return nil unless reimbursement
|
|
501
|
+
return nil if reimbursement.limitations.empty?
|
|
502
|
+
|
|
503
|
+
# Convert FHIR limitations to OpenStruct format
|
|
504
|
+
limitations = OpenStruct.new
|
|
505
|
+
limitations.Limitation = reimbursement.limitations.map do |lim|
|
|
506
|
+
limitation = OpenStruct.new
|
|
507
|
+
limitation.LimitationCode = "" # Not in FHIR
|
|
508
|
+
limitation.LimitationType = "" # Could derive from status
|
|
509
|
+
limitation.LimitationNiveau = "" # Not in FHIR
|
|
510
|
+
limitation.LimitationValue = "" # Not in FHIR
|
|
511
|
+
limitation.DescriptionDe = lim[:text] || ""
|
|
512
|
+
limitation.DescriptionFr = "" # May need separate language version
|
|
513
|
+
limitation.DescriptionIt = "" # May need separate language version
|
|
514
|
+
limitation.ValidFromDate = lim[:status_date] || lim[:start_date] || ""
|
|
515
|
+
limitation.ValidThruDate = lim[:end_date] || ""
|
|
516
|
+
limitation
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
limitations
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def get_cost_share_for_package(bundle, package)
|
|
523
|
+
# Find reimbursement authorization for this package
|
|
524
|
+
reimbursement = bundle.authorizations.find do |auth|
|
|
525
|
+
auth.reimbursement_sl? && auth.subject_reference&.end_with?(package.resource_id)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
reimbursement&.cost_share
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def map_org_gen_code(classification)
|
|
532
|
+
return nil unless classification
|
|
533
|
+
|
|
534
|
+
case classification
|
|
535
|
+
when "756001003001"
|
|
536
|
+
"G"
|
|
537
|
+
when "756001003002"
|
|
538
|
+
"O"
|
|
539
|
+
else
|
|
540
|
+
nil
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def map_legal_status(status_code)
|
|
545
|
+
return nil unless status_code
|
|
546
|
+
|
|
547
|
+
# Map FHIR codes to Swissmedic categories
|
|
548
|
+
case status_code
|
|
549
|
+
when "756005022001"
|
|
550
|
+
"A"
|
|
551
|
+
when "756005022003"
|
|
552
|
+
"B"
|
|
553
|
+
when "756005022005"
|
|
554
|
+
"C"
|
|
555
|
+
when "756005022007"
|
|
556
|
+
"D"
|
|
557
|
+
when "756005022009"
|
|
558
|
+
"E"
|
|
559
|
+
else
|
|
560
|
+
nil
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# FHIR Extractor - Compatible with existing BagXmlExtractor
|
|
567
|
+
class FhirExtractor < Extractor
|
|
568
|
+
def initialize(fhir_file)
|
|
569
|
+
@fhir_file = fhir_file
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def to_hash
|
|
573
|
+
data = {}
|
|
574
|
+
Oddb2xml.log "FhirExtractor: Parsing FHIR file #{@fhir_file}"
|
|
575
|
+
|
|
576
|
+
# Parse FHIR NDJSON
|
|
577
|
+
result = FhirPreparationsEntry.parse(@fhir_file)
|
|
578
|
+
|
|
579
|
+
result.Preparations.Preparation.each do |seq|
|
|
580
|
+
next unless seq
|
|
581
|
+
next if seq.SwissmedicNo5 && seq.SwissmedicNo5.eql?("0")
|
|
582
|
+
|
|
583
|
+
# Build item structure matching BagXmlExtractor
|
|
584
|
+
item = {}
|
|
585
|
+
item[:data_origin] = "fhir"
|
|
586
|
+
item[:refdata] = true
|
|
587
|
+
item[:product_key] = nil # Not available in FHIR
|
|
588
|
+
item[:desc_de] = "" # Not in FHIR at product level
|
|
589
|
+
item[:desc_fr] = ""
|
|
590
|
+
item[:desc_it] = ""
|
|
591
|
+
item[:name_de] = (name = seq.NameDe) ? name : ""
|
|
592
|
+
item[:name_fr] = (name = seq.NameFr) ? name : ""
|
|
593
|
+
item[:name_it] = (name = seq.NameIt) ? name : ""
|
|
594
|
+
item[:swissmedic_number5] = (num5 = seq.SwissmedicNo5) ? num5.to_s.rjust(5, "0") : ""
|
|
595
|
+
item[:org_gen_code] = (orgc = seq.OrgGenCode) ? orgc : ""
|
|
596
|
+
item[:deductible] = "" # Will be set per package based on cost_share
|
|
597
|
+
item[:deductible20] = "" # Will be set per package based on cost_share
|
|
598
|
+
item[:atc_code] = (atcc = seq.AtcCode) ? atcc : ""
|
|
599
|
+
item[:comment_de] = "" # Not available in FHIR
|
|
600
|
+
item[:comment_fr] = ""
|
|
601
|
+
item[:comment_it] = ""
|
|
602
|
+
item[:it_code] = (itc = seq.ItCode) ? itc : "" # NOW available in FHIR!
|
|
603
|
+
|
|
604
|
+
# Build substances array
|
|
605
|
+
item[:substances] = []
|
|
606
|
+
if seq.Substances && seq.Substances.Substance
|
|
607
|
+
seq.Substances.Substance.each_with_index do |sub, i|
|
|
608
|
+
item[:substances] << {
|
|
609
|
+
index: i.to_s,
|
|
610
|
+
name: (name = sub.DescriptionLa) ? name : "",
|
|
611
|
+
quantity: (qtty = sub.Quantity) ? qtty : "",
|
|
612
|
+
unit: (unit = sub.QuantityUnit) ? unit : ""
|
|
613
|
+
}
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
item[:pharmacodes] = []
|
|
618
|
+
item[:packages] = {}
|
|
619
|
+
|
|
620
|
+
# Process packages
|
|
621
|
+
if seq.Packs && seq.Packs.Pack
|
|
622
|
+
seq.Packs.Pack.each do |pac|
|
|
623
|
+
next unless pac.GTIN
|
|
624
|
+
|
|
625
|
+
ean13 = pac.GTIN.to_s
|
|
626
|
+
|
|
627
|
+
# Ensure SwissmedicNo8 has leading zeros
|
|
628
|
+
if pac.SwissmedicNo8 && pac.SwissmedicNo8.length < 8
|
|
629
|
+
pac.SwissmedicNo8 = pac.SwissmedicNo8.rjust(8, "0")
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
Oddb2xml.setEan13forNo8(pac.SwissmedicNo8, ean13) if pac.SwissmedicNo8
|
|
633
|
+
|
|
634
|
+
# Build price structures
|
|
635
|
+
exf = {price: "", valid_date: "", price_code: ""}
|
|
636
|
+
if pac.Prices && pac.Prices.ExFactoryPrice
|
|
637
|
+
exf[:price] = pac.Prices.ExFactoryPrice.Price.to_s if pac.Prices.ExFactoryPrice.Price
|
|
638
|
+
exf[:valid_date] = pac.Prices.ExFactoryPrice.ValidFromDate if pac.Prices.ExFactoryPrice.ValidFromDate
|
|
639
|
+
exf[:price_code] = "PEXF"
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
pub = {price: "", valid_date: "", price_code: ""}
|
|
643
|
+
if pac.Prices && pac.Prices.PublicPrice
|
|
644
|
+
pub[:price] = pac.Prices.PublicPrice.Price.to_s if pac.Prices.PublicPrice.Price
|
|
645
|
+
pub[:valid_date] = pac.Prices.PublicPrice.ValidFromDate if pac.Prices.PublicPrice.ValidFromDate
|
|
646
|
+
pub[:price_code] = "PPUB"
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Build package entry matching BagXmlExtractor structure
|
|
650
|
+
item[:packages][ean13] = {
|
|
651
|
+
ean13: ean13,
|
|
652
|
+
name_de: (name = seq.NameDe) ? name : "",
|
|
653
|
+
name_fr: (name = seq.NameFr) ? name : "",
|
|
654
|
+
name_it: (name = seq.NameIt) ? name : "",
|
|
655
|
+
desc_de: (desc = pac.DescriptionDe) ? desc : "",
|
|
656
|
+
desc_fr: (desc = pac.DescriptionFr) ? desc : "",
|
|
657
|
+
desc_it: (desc = pac.DescriptionIt) ? desc : "",
|
|
658
|
+
sl_entry: true,
|
|
659
|
+
swissmedic_category: (cat = pac.SwissmedicCategory) ? cat : "",
|
|
660
|
+
swissmedic_number8: (num = pac.SwissmedicNo8) ? num : "",
|
|
661
|
+
prices: {exf_price: exf, pub_price: pub}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
# Map limitations from FHIR
|
|
665
|
+
item[:packages][ean13][:limitations] = []
|
|
666
|
+
if pac.Limitations && pac.Limitations.Limitation
|
|
667
|
+
pac.Limitations.Limitation.each do |lim|
|
|
668
|
+
# Calculate is_deleted safely
|
|
669
|
+
is_deleted = false
|
|
670
|
+
if lim.ValidThruDate
|
|
671
|
+
begin
|
|
672
|
+
is_deleted = Date.parse(lim.ValidThruDate) < Date.today
|
|
673
|
+
rescue
|
|
674
|
+
is_deleted = false
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
item[:packages][ean13][:limitations] << {
|
|
679
|
+
it: item[:it_code],
|
|
680
|
+
key: :swissmedic_number8,
|
|
681
|
+
id: pac.SwissmedicNo8 || "",
|
|
682
|
+
code: lim.LimitationCode || "",
|
|
683
|
+
type: lim.LimitationType || "",
|
|
684
|
+
value: lim.LimitationValue || "",
|
|
685
|
+
niv: lim.LimitationNiveau || "",
|
|
686
|
+
desc_de: lim.DescriptionDe || "",
|
|
687
|
+
desc_fr: lim.DescriptionFr || "",
|
|
688
|
+
desc_it: lim.DescriptionIt || "",
|
|
689
|
+
vdate: lim.ValidFromDate || "",
|
|
690
|
+
del: is_deleted
|
|
691
|
+
}
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
item[:packages][ean13][:limitation_points] = ""
|
|
695
|
+
|
|
696
|
+
# Map cost_share to deductible flags
|
|
697
|
+
if pac.CostShare
|
|
698
|
+
case pac.CostShare
|
|
699
|
+
when 10
|
|
700
|
+
item[:deductible] = "Y"
|
|
701
|
+
when 20
|
|
702
|
+
item[:deductible20] = "Y"
|
|
703
|
+
when 40
|
|
704
|
+
# New value - might need new field or special handling
|
|
705
|
+
item[:deductible] = "Y" # Fallback to standard deductible
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
# Store in data hash with ean13 as key
|
|
710
|
+
data[ean13] = item
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
Oddb2xml.log "FhirExtractor: Extracted #{data.size} packages"
|
|
716
|
+
data
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Compatibility layer - makes FHIR parser compatible with existing XML parser usage
|
|
722
|
+
class FhirPreparationsEntry
|
|
723
|
+
attr_reader :Preparations
|
|
724
|
+
|
|
725
|
+
def self.parse(ndjson_file)
|
|
726
|
+
parser = Oddb2xml::FHIR::PreparationsParser.new(ndjson_file)
|
|
727
|
+
entry = new
|
|
728
|
+
entry.Preparations = OpenStruct.new
|
|
729
|
+
entry.Preparations.Preparation = parser.preparations
|
|
730
|
+
entry
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
attr_writer :Preparations
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# Extend PreparationsEntry to handle both XML and FHIR
|
|
737
|
+
class PreparationsEntry
|
|
738
|
+
class << self
|
|
739
|
+
alias_method :original_parse, :parse
|
|
740
|
+
|
|
741
|
+
def parse(input, **kwargs)
|
|
742
|
+
# Check if input is a file path ending in .ndjson
|
|
743
|
+
if input.is_a?(String) && File.exist?(input) && input.end_with?(".ndjson")
|
|
744
|
+
# Parse as FHIR
|
|
745
|
+
FhirPreparationsEntry.parse(input)
|
|
746
|
+
else
|
|
747
|
+
# Parse as XML (original behavior)
|
|
748
|
+
original_parse(input, **kwargs)
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
end
|