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