oddb2xml 3.0.0 → 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 +48 -43
- data/lib/oddb2xml/cli.rb +23 -3
- data/lib/oddb2xml/compressor.rb +0 -2
- 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 +7 -7
- metadata +24 -22
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0521593bc12b423a74628f04973dcaf43241ce674c415c4e3a82da3ad34a134
|
|
4
|
+
data.tar.gz: a7b8fe9076dd737105696b525e3c4c0e17e3174ea4d86d0bab9f948324229c05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f0c6275adc2b60930eb961b1745013f81c167a49c035347c49b40af8f59ead028f884981aeb9aa0ce809b7007a1b4c78993d2adfa5c3e31e3ed56e8866ac035
|
|
7
|
+
data.tar.gz: c02f68d34884adbabe1465cf7180e6ef51cbb84049a71a931880bcb383abb4c3dc8196f92e21932b3529a68e5211f546fb053112f27037434313517de4d22961
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
oddb2xml is a Ruby gem that downloads Swiss pharmaceutical data from 10+ sources (Swissmedic, BAG, Refdata, ZurRose, EPha, etc.), parses multiple formats (XML, XLSX, CSV, SOAP, fixed-width DAT), merges/deduplicates them, and generates standardized XML/DAT output files for healthcare systems. It also supports the Elexis EHR Artikelstamm format.
|
|
8
|
+
|
|
9
|
+
## Common Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Install dependencies
|
|
13
|
+
bundle install
|
|
14
|
+
|
|
15
|
+
# Run full test suite
|
|
16
|
+
bundle exec rake spec
|
|
17
|
+
|
|
18
|
+
# Run a single test file
|
|
19
|
+
bundle exec rspec spec/builder_spec.rb
|
|
20
|
+
|
|
21
|
+
# Run a single test by line number
|
|
22
|
+
bundle exec rspec spec/builder_spec.rb:42
|
|
23
|
+
|
|
24
|
+
# Lint with StandardRB
|
|
25
|
+
bundle exec standardrb
|
|
26
|
+
|
|
27
|
+
# Auto-fix lint issues
|
|
28
|
+
bundle exec standardrb --fix
|
|
29
|
+
|
|
30
|
+
# Build the gem
|
|
31
|
+
bundle exec rake build
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Architecture
|
|
35
|
+
|
|
36
|
+
The system follows a **download → extract → build → compress** pipeline:
|
|
37
|
+
|
|
38
|
+
1. **CLI** (`lib/oddb2xml/cli.rb`) — Entry point. Parses options via Optimist (`options.rb`), orchestrates the pipeline, manages multi-threaded downloads.
|
|
39
|
+
|
|
40
|
+
2. **Downloaders** (`lib/oddb2xml/downloader.rb`) — 11 subclasses of `Downloader`, each fetching from a specific Swiss data source. Files cached in `./downloads/`.
|
|
41
|
+
|
|
42
|
+
3. **Extractors** (`lib/oddb2xml/extractor.rb`) — Matching extractor classes that parse downloaded files into Ruby hashes. Formats include XML (nokogiri/sax-machine), XLSX (rubyXL), SOAP (savon), CSV, and fixed-width text.
|
|
43
|
+
|
|
44
|
+
4. **Builder** (`lib/oddb2xml/builder.rb`) — The largest file (~1900 lines). Merges extracted data and generates output XML/DAT files. Methods follow `prepare_*` (data assembly) and `build_*` (output generation) naming.
|
|
45
|
+
|
|
46
|
+
5. **Calc** (`lib/oddb2xml/calc.rb`) — Composition calculation logic, works with `parslet_compositions.rb` and `compositions_syntax.rb` (Parslet-based PEG parser for drug composition strings).
|
|
47
|
+
|
|
48
|
+
6. **Compressor** (`lib/oddb2xml/compressor.rb`) — Optional ZIP/TAR.GZ output compression.
|
|
49
|
+
|
|
50
|
+
### Key data identifiers
|
|
51
|
+
- **GTIN/EAN13**: Primary article identifier (13-digit barcode)
|
|
52
|
+
- **Pharmacode**: Swiss pharmacy code
|
|
53
|
+
- **IKSNR**: Swissmedic registration number (5-digit)
|
|
54
|
+
- **Swissmedic sequence/pack numbers**: Combined with IKSNR to form full identifiers
|
|
55
|
+
|
|
56
|
+
### Static data overrides
|
|
57
|
+
YAML files in `data/` provide manual overrides and mappings: `article_overrides.yaml`, `product_overrides.yaml`, `gtin2ignore.yaml`, `gal_forms.yaml`, `gal_groups.yaml`.
|
|
58
|
+
|
|
59
|
+
## Testing
|
|
60
|
+
|
|
61
|
+
- Framework: RSpec with flexmock (mocking), webmock + VCR (HTTP recording/playback)
|
|
62
|
+
- Test fixtures: `spec/data/` (sample files), `spec/fixtures/vcr_cassettes/` (recorded HTTP responses)
|
|
63
|
+
- `spec/spec_helper.rb` defines test constants (GTINs) and configures VCR to avoid real HTTP calls during tests
|
|
64
|
+
- CI runs on Ruby 3.0, 3.1, 3.2
|
|
65
|
+
|
|
66
|
+
## Ruby Version
|
|
67
|
+
|
|
68
|
+
- Minimum: Ruby >= 2.5.0 (gemspec)
|
|
69
|
+
- Current development: Ruby 3.2.0 (`.ruby-version`)
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -4,50 +4,50 @@ PATH
|
|
|
4
4
|
oddb2xml (3.0.0)
|
|
5
5
|
htmlentities
|
|
6
6
|
httpi
|
|
7
|
-
mechanize
|
|
7
|
+
mechanize (>= 2.8.5)
|
|
8
8
|
minitar
|
|
9
9
|
multi_json
|
|
10
|
-
nokogiri (>= 1.
|
|
10
|
+
nokogiri (>= 1.19.1)
|
|
11
11
|
optimist
|
|
12
12
|
ox
|
|
13
13
|
parslet
|
|
14
|
-
rack (
|
|
15
|
-
rexml
|
|
14
|
+
rack (>= 3.1.20)
|
|
15
|
+
rexml (>= 3.3.9)
|
|
16
16
|
rubyXL (~> 3.4.0)
|
|
17
|
-
rubyntlm (
|
|
17
|
+
rubyntlm (>= 0.6.3)
|
|
18
18
|
rubyzip (~> 3.0.1)
|
|
19
19
|
savon (~> 2.12.0)
|
|
20
20
|
sax-machine
|
|
21
21
|
spreadsheet
|
|
22
22
|
standardrb
|
|
23
|
-
webrick
|
|
23
|
+
webrick (>= 1.8.2)
|
|
24
24
|
xml-simple
|
|
25
25
|
|
|
26
26
|
GEM
|
|
27
27
|
remote: https://rubygems.org/
|
|
28
28
|
specs:
|
|
29
|
-
addressable (2.8.
|
|
30
|
-
public_suffix (>= 2.0.2, <
|
|
29
|
+
addressable (2.8.8)
|
|
30
|
+
public_suffix (>= 2.0.2, < 8.0)
|
|
31
31
|
akami (1.3.1)
|
|
32
32
|
gyoku (>= 0.4.0)
|
|
33
33
|
nokogiri
|
|
34
34
|
ast (2.4.2)
|
|
35
|
+
base64 (0.3.0)
|
|
35
36
|
builder (3.2.4)
|
|
36
37
|
byebug (11.1.3)
|
|
37
38
|
coderay (1.1.3)
|
|
38
|
-
connection_pool (
|
|
39
|
+
connection_pool (3.0.2)
|
|
39
40
|
crack (0.4.5)
|
|
40
41
|
rexml
|
|
41
42
|
diff-lcs (1.5.0)
|
|
42
|
-
domain_name (0.
|
|
43
|
-
unf (>= 0.0.5, < 1.0.0)
|
|
43
|
+
domain_name (0.6.20240107)
|
|
44
44
|
flexmock (2.3.8)
|
|
45
45
|
gyoku (1.4.0)
|
|
46
46
|
builder (>= 2.1.2)
|
|
47
47
|
rexml (~> 3.0)
|
|
48
48
|
hashdiff (1.0.1)
|
|
49
49
|
htmlentities (4.3.4)
|
|
50
|
-
http-cookie (1.0
|
|
50
|
+
http-cookie (1.1.0)
|
|
51
51
|
domain_name (~> 0.5)
|
|
52
52
|
httpi (2.5.0)
|
|
53
53
|
rack
|
|
@@ -55,31 +55,37 @@ GEM
|
|
|
55
55
|
json (2.6.3)
|
|
56
56
|
language_server-protocol (3.17.0.3)
|
|
57
57
|
lint_roller (1.1.0)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
logger (1.7.0)
|
|
59
|
+
mechanize (2.14.0)
|
|
60
|
+
addressable (~> 2.8)
|
|
61
|
+
base64
|
|
62
|
+
domain_name (~> 0.5, >= 0.5.20190701)
|
|
63
|
+
http-cookie (~> 1.0, >= 1.0.3)
|
|
64
|
+
mime-types (~> 3.3)
|
|
65
|
+
net-http-digest_auth (~> 1.4, >= 1.4.1)
|
|
66
|
+
net-http-persistent (>= 2.5.2, < 5.0.dev)
|
|
67
|
+
nkf
|
|
68
|
+
nokogiri (~> 1.11, >= 1.11.2)
|
|
69
|
+
rubyntlm (~> 0.6, >= 0.6.3)
|
|
66
70
|
webrick (~> 1.7)
|
|
67
|
-
webrobots (
|
|
71
|
+
webrobots (~> 0.1.2)
|
|
68
72
|
method_source (1.0.0)
|
|
69
|
-
mime-types (3.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
mime-types (3.7.0)
|
|
74
|
+
logger
|
|
75
|
+
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
|
76
|
+
mime-types-data (3.2026.0203)
|
|
77
|
+
mini_portile2 (2.8.9)
|
|
73
78
|
minitar (0.9)
|
|
74
79
|
multi_json (1.15.0)
|
|
80
|
+
mutex_m (0.3.0)
|
|
75
81
|
net-http-digest_auth (1.4.1)
|
|
76
|
-
net-http-persistent (4.0.
|
|
77
|
-
connection_pool (
|
|
78
|
-
|
|
79
|
-
|
|
82
|
+
net-http-persistent (4.0.8)
|
|
83
|
+
connection_pool (>= 2.2.4, < 4)
|
|
84
|
+
nkf (0.2.0)
|
|
85
|
+
nokogiri (1.19.1)
|
|
86
|
+
mini_portile2 (~> 2.8.2)
|
|
80
87
|
racc (~> 1.4)
|
|
81
88
|
nori (2.6.0)
|
|
82
|
-
ntlm-http (0.1.1)
|
|
83
89
|
optimist (3.1.0)
|
|
84
90
|
ox (2.14.14)
|
|
85
91
|
parallel (1.23.0)
|
|
@@ -97,14 +103,14 @@ GEM
|
|
|
97
103
|
pry (~> 0.11)
|
|
98
104
|
yard (~> 0.9.11)
|
|
99
105
|
psych (3.3.4)
|
|
100
|
-
public_suffix (
|
|
101
|
-
racc (1.
|
|
102
|
-
rack (3.
|
|
106
|
+
public_suffix (7.0.2)
|
|
107
|
+
racc (1.8.1)
|
|
108
|
+
rack (3.2.5)
|
|
103
109
|
rainbow (3.1.1)
|
|
104
110
|
rake (13.0.6)
|
|
105
|
-
rdoc (6.3.
|
|
111
|
+
rdoc (6.3.4.1)
|
|
106
112
|
regexp_parser (2.8.1)
|
|
107
|
-
rexml (3.
|
|
113
|
+
rexml (3.4.4)
|
|
108
114
|
rspec (3.12.0)
|
|
109
115
|
rspec-core (~> 3.12.0)
|
|
110
116
|
rspec-expectations (~> 3.12.0)
|
|
@@ -138,7 +144,8 @@ GEM
|
|
|
138
144
|
rubyXL (3.4.25)
|
|
139
145
|
nokogiri (>= 1.10.8)
|
|
140
146
|
rubyzip (>= 1.3.0)
|
|
141
|
-
rubyntlm (0.5
|
|
147
|
+
rubyntlm (0.6.5)
|
|
148
|
+
base64
|
|
142
149
|
rubyzip (3.0.1)
|
|
143
150
|
savon (2.12.1)
|
|
144
151
|
akami (~> 1.2)
|
|
@@ -167,9 +174,6 @@ GEM
|
|
|
167
174
|
standardrb (1.0.1)
|
|
168
175
|
standard
|
|
169
176
|
timecop (0.9.8)
|
|
170
|
-
unf (0.1.4)
|
|
171
|
-
unf_ext
|
|
172
|
-
unf_ext (0.0.8.2)
|
|
173
177
|
unicode-display_width (2.5.0)
|
|
174
178
|
vcr (6.1.0)
|
|
175
179
|
wasabi (3.7.0)
|
|
@@ -180,7 +184,7 @@ GEM
|
|
|
180
184
|
addressable (>= 2.8.0)
|
|
181
185
|
crack (>= 0.3.2)
|
|
182
186
|
hashdiff (>= 0.4.0, < 2.0.0)
|
|
183
|
-
webrick (1.
|
|
187
|
+
webrick (1.9.2)
|
|
184
188
|
webrobots (0.1.2)
|
|
185
189
|
xml-simple (1.1.9)
|
|
186
190
|
rexml
|
|
@@ -192,14 +196,15 @@ PLATFORMS
|
|
|
192
196
|
DEPENDENCIES
|
|
193
197
|
bundler
|
|
194
198
|
flexmock
|
|
195
|
-
|
|
199
|
+
mutex_m
|
|
200
|
+
nokogiri (>= 1.19.1)
|
|
196
201
|
oddb2xml!
|
|
197
202
|
pry-byebug
|
|
198
203
|
pry-doc
|
|
199
204
|
psych (< 4.0.0)
|
|
200
|
-
rack (
|
|
205
|
+
rack (>= 3.1.20)
|
|
201
206
|
rake
|
|
202
|
-
rdoc (
|
|
207
|
+
rdoc (>= 6.3.4.1)
|
|
203
208
|
rspec
|
|
204
209
|
timecop
|
|
205
210
|
vcr
|
data/lib/oddb2xml/cli.rb
CHANGED
|
@@ -60,14 +60,21 @@ module Oddb2xml
|
|
|
60
60
|
threads << download(:zurrose)
|
|
61
61
|
threads << download(:package) # swissmedic
|
|
62
62
|
threads << download(:lppv) # oddb2xml_files
|
|
63
|
-
|
|
63
|
+
|
|
64
|
+
# Use FHIR or XML for BAG data
|
|
65
|
+
if @options[:fhir]
|
|
66
|
+
threads << download(:fhir) # FHIR from FOPH/BAG
|
|
67
|
+
else
|
|
68
|
+
threads << download(:bag) # XML from bag.e-mediat
|
|
69
|
+
end
|
|
70
|
+
|
|
64
71
|
if @options[:firstbase]
|
|
65
72
|
threads << download(:firstbase) # https://github.com/zdavatz/oddb2xml/issues/63
|
|
66
73
|
end
|
|
67
74
|
types.each do |type|
|
|
68
75
|
begin
|
|
69
76
|
threads << download(:refdata, type) # refdata
|
|
70
|
-
rescue error
|
|
77
|
+
rescue => error
|
|
71
78
|
# Should continue even when error #102
|
|
72
79
|
Oddb2xml.log("Error in downloading refdata #{error}")
|
|
73
80
|
end
|
|
@@ -274,6 +281,19 @@ module Oddb2xml
|
|
|
274
281
|
@lppvs
|
|
275
282
|
end
|
|
276
283
|
|
|
284
|
+
when :fhir
|
|
285
|
+
# instead of Thread.new do
|
|
286
|
+
|
|
287
|
+
downloader = FhirDownloader.new(@options)
|
|
288
|
+
fhir_file = downloader.download
|
|
289
|
+
Oddb2xml.log("FhirDownloader downloaded #{File.size(fhir_file)} bytes")
|
|
290
|
+
@mutex.synchronize do
|
|
291
|
+
hsh = FhirExtractor.new(fhir_file).to_hash
|
|
292
|
+
@items = hsh
|
|
293
|
+
Oddb2xml.log("FhirExtractor added #{@items.size} items from FHIR")
|
|
294
|
+
@items
|
|
295
|
+
end
|
|
296
|
+
|
|
277
297
|
when :bag
|
|
278
298
|
# instead of Thread.new do
|
|
279
299
|
|
|
@@ -321,7 +341,7 @@ module Oddb2xml
|
|
|
321
341
|
@refdata_types[type] = hsh
|
|
322
342
|
Oddb2xml.log("RefdataExtractor #{type} added #{hsh.size} keys now #{@refdata_types.keys} items from xml with #{xml.size} bytes")
|
|
323
343
|
@refdata_types[type]
|
|
324
|
-
rescue error
|
|
344
|
+
rescue => error
|
|
325
345
|
# Should continue even when error https://github.com/zdavatz/oddb2xml/issues/102
|
|
326
346
|
Oddb2xml.log("Error in RefdataExtractor #{error}")
|
|
327
347
|
end
|
data/lib/oddb2xml/compressor.rb
CHANGED
|
@@ -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
|
data/lib/oddb2xml/options.rb
CHANGED
|
@@ -22,6 +22,8 @@ module Oddb2xml
|
|
|
22
22
|
opt :extended, "pharma, non-pharma plus prices and non-pharma from zurrose.
|
|
23
23
|
Products without EAN-Code will also be listed.
|
|
24
24
|
File oddb_calc.xml will also be generated"
|
|
25
|
+
opt :fhir, "Use FHIR NDJSON format from FOPH/BAG instead of XML from Spezialitätenliste", default: false
|
|
26
|
+
opt :fhir_url, "Specific FHIR NDJSON URL to download (implies --fhir)", type: :string, default: nil
|
|
25
27
|
opt :format, "File format F, default is xml. {xml|dat}
|
|
26
28
|
If F is given, -o option is ignored.", type: :string, default: "xml"
|
|
27
29
|
opt :include, "Include target option for ean14 for 'dat' format.
|
|
@@ -69,6 +71,10 @@ module Oddb2xml
|
|
|
69
71
|
@opts[:extended] = true
|
|
70
72
|
@opts[:price] = :zurrose
|
|
71
73
|
end
|
|
74
|
+
# FHIR URL implies FHIR mode
|
|
75
|
+
if @opts[:fhir_url]
|
|
76
|
+
@opts[:fhir] = true
|
|
77
|
+
end
|
|
72
78
|
@opts[:price] = :zurrose if @opts[:price].is_a?(TrueClass)
|
|
73
79
|
@opts[:price] = @opts[:price].to_sym if @opts[:price]
|
|
74
80
|
@opts[:ean14] = @opts[:include]
|
data/lib/oddb2xml/version.rb
CHANGED
data/lib/oddb2xml.rb
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "oddb2xml/version"
|
|
4
|
+
require "oddb2xml/util"
|
|
5
|
+
require "oddb2xml/options"
|
|
6
|
+
require "oddb2xml/downloader"
|
|
7
|
+
require "oddb2xml/xml_definitions"
|
|
8
|
+
require "oddb2xml/extractor"
|
|
9
|
+
require "oddb2xml/builder"
|
|
10
|
+
require "oddb2xml/fhir_support"
|
|
2
11
|
require "oddb2xml/cli"
|
|
3
12
|
|
|
4
13
|
module Oddb2xml
|
|
14
|
+
class Error < StandardError; end
|
|
5
15
|
end
|
data/oddb2xml.gemspec
CHANGED
|
@@ -22,30 +22,30 @@ Gem::Specification.new do |spec|
|
|
|
22
22
|
# Consulted the Gemfile.lock to get
|
|
23
23
|
spec.add_dependency "rubyzip", '~> 3.0.1'
|
|
24
24
|
spec.add_dependency "minitar" # , '~> 0.5.2'
|
|
25
|
-
spec.add_dependency "mechanize"
|
|
26
|
-
spec.add_dependency "nokogiri", ">= 1.
|
|
25
|
+
spec.add_dependency "mechanize", ">= 2.8.5"
|
|
26
|
+
spec.add_dependency "nokogiri", ">= 1.19.1"
|
|
27
27
|
spec.add_dependency "savon" , '~> 2.12.0'
|
|
28
28
|
spec.add_dependency "spreadsheet" # , '~> 1.0.0'
|
|
29
29
|
spec.add_dependency "rubyXL", "~> 3.4.0"
|
|
30
30
|
spec.add_dependency "sax-machine" # , '~> 0.1.0'
|
|
31
31
|
spec.add_dependency "parslet" # , '~> 1.7.0'
|
|
32
|
-
spec.add_dependency "rubyntlm", "0.
|
|
32
|
+
spec.add_dependency "rubyntlm", ">= 0.6.3"
|
|
33
33
|
spec.add_dependency "multi_json" # , '>= 0.3.2'
|
|
34
34
|
spec.add_dependency "httpi" # , '>= 2.4.1'
|
|
35
35
|
spec.add_dependency "optimist"
|
|
36
36
|
spec.add_dependency "xml-simple"
|
|
37
37
|
spec.add_dependency "ox"
|
|
38
38
|
spec.add_dependency "htmlentities"
|
|
39
|
-
spec.add_dependency "webrick"
|
|
40
|
-
spec.add_dependency "rexml"
|
|
39
|
+
spec.add_dependency "webrick", ">= 1.8.2"
|
|
40
|
+
spec.add_dependency "rexml", ">= 3.3.9"
|
|
41
41
|
spec.add_dependency "standardrb"
|
|
42
|
-
spec.add_dependency "rack", "3.
|
|
42
|
+
spec.add_dependency "rack", ">= 3.1.20"
|
|
43
43
|
|
|
44
44
|
spec.add_development_dependency "bundler"
|
|
45
45
|
spec.add_development_dependency "rake"
|
|
46
46
|
spec.add_development_dependency "rspec"
|
|
47
47
|
spec.add_development_dependency "webmock"
|
|
48
|
-
spec.add_development_dependency "rdoc", "
|
|
48
|
+
spec.add_development_dependency "rdoc", ">= 6.3.4.1"
|
|
49
49
|
spec.add_development_dependency "vcr"
|
|
50
50
|
spec.add_development_dependency "timecop"
|
|
51
51
|
spec.add_development_dependency "flexmock"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: oddb2xml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.0.
|
|
4
|
+
version: 3.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yasuhiro Asaka, Zeno R.R. Davatz, Niklaus Giger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-02-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rubyzip
|
|
@@ -44,28 +44,28 @@ dependencies:
|
|
|
44
44
|
requirements:
|
|
45
45
|
- - ">="
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version:
|
|
47
|
+
version: 2.8.5
|
|
48
48
|
type: :runtime
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
52
|
- - ">="
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version:
|
|
54
|
+
version: 2.8.5
|
|
55
55
|
- !ruby/object:Gem::Dependency
|
|
56
56
|
name: nokogiri
|
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
|
58
58
|
requirements:
|
|
59
59
|
- - ">="
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: 1.
|
|
61
|
+
version: 1.19.1
|
|
62
62
|
type: :runtime
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - ">="
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: 1.
|
|
68
|
+
version: 1.19.1
|
|
69
69
|
- !ruby/object:Gem::Dependency
|
|
70
70
|
name: savon
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -140,16 +140,16 @@ dependencies:
|
|
|
140
140
|
name: rubyntlm
|
|
141
141
|
requirement: !ruby/object:Gem::Requirement
|
|
142
142
|
requirements:
|
|
143
|
-
- -
|
|
143
|
+
- - ">="
|
|
144
144
|
- !ruby/object:Gem::Version
|
|
145
|
-
version: 0.
|
|
145
|
+
version: 0.6.3
|
|
146
146
|
type: :runtime
|
|
147
147
|
prerelease: false
|
|
148
148
|
version_requirements: !ruby/object:Gem::Requirement
|
|
149
149
|
requirements:
|
|
150
|
-
- -
|
|
150
|
+
- - ">="
|
|
151
151
|
- !ruby/object:Gem::Version
|
|
152
|
-
version: 0.
|
|
152
|
+
version: 0.6.3
|
|
153
153
|
- !ruby/object:Gem::Dependency
|
|
154
154
|
name: multi_json
|
|
155
155
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -240,28 +240,28 @@ dependencies:
|
|
|
240
240
|
requirements:
|
|
241
241
|
- - ">="
|
|
242
242
|
- !ruby/object:Gem::Version
|
|
243
|
-
version:
|
|
243
|
+
version: 1.8.2
|
|
244
244
|
type: :runtime
|
|
245
245
|
prerelease: false
|
|
246
246
|
version_requirements: !ruby/object:Gem::Requirement
|
|
247
247
|
requirements:
|
|
248
248
|
- - ">="
|
|
249
249
|
- !ruby/object:Gem::Version
|
|
250
|
-
version:
|
|
250
|
+
version: 1.8.2
|
|
251
251
|
- !ruby/object:Gem::Dependency
|
|
252
252
|
name: rexml
|
|
253
253
|
requirement: !ruby/object:Gem::Requirement
|
|
254
254
|
requirements:
|
|
255
255
|
- - ">="
|
|
256
256
|
- !ruby/object:Gem::Version
|
|
257
|
-
version:
|
|
257
|
+
version: 3.3.9
|
|
258
258
|
type: :runtime
|
|
259
259
|
prerelease: false
|
|
260
260
|
version_requirements: !ruby/object:Gem::Requirement
|
|
261
261
|
requirements:
|
|
262
262
|
- - ">="
|
|
263
263
|
- !ruby/object:Gem::Version
|
|
264
|
-
version:
|
|
264
|
+
version: 3.3.9
|
|
265
265
|
- !ruby/object:Gem::Dependency
|
|
266
266
|
name: standardrb
|
|
267
267
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -280,16 +280,16 @@ dependencies:
|
|
|
280
280
|
name: rack
|
|
281
281
|
requirement: !ruby/object:Gem::Requirement
|
|
282
282
|
requirements:
|
|
283
|
-
- -
|
|
283
|
+
- - ">="
|
|
284
284
|
- !ruby/object:Gem::Version
|
|
285
|
-
version: 3.
|
|
285
|
+
version: 3.1.20
|
|
286
286
|
type: :runtime
|
|
287
287
|
prerelease: false
|
|
288
288
|
version_requirements: !ruby/object:Gem::Requirement
|
|
289
289
|
requirements:
|
|
290
|
-
- -
|
|
290
|
+
- - ">="
|
|
291
291
|
- !ruby/object:Gem::Version
|
|
292
|
-
version: 3.
|
|
292
|
+
version: 3.1.20
|
|
293
293
|
- !ruby/object:Gem::Dependency
|
|
294
294
|
name: bundler
|
|
295
295
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -350,16 +350,16 @@ dependencies:
|
|
|
350
350
|
name: rdoc
|
|
351
351
|
requirement: !ruby/object:Gem::Requirement
|
|
352
352
|
requirements:
|
|
353
|
-
- - "
|
|
353
|
+
- - ">="
|
|
354
354
|
- !ruby/object:Gem::Version
|
|
355
|
-
version: 6.3.
|
|
355
|
+
version: 6.3.4.1
|
|
356
356
|
type: :development
|
|
357
357
|
prerelease: false
|
|
358
358
|
version_requirements: !ruby/object:Gem::Requirement
|
|
359
359
|
requirements:
|
|
360
|
-
- - "
|
|
360
|
+
- - ">="
|
|
361
361
|
- !ruby/object:Gem::Version
|
|
362
|
-
version: 6.3.
|
|
362
|
+
version: 6.3.4.1
|
|
363
363
|
- !ruby/object:Gem::Dependency
|
|
364
364
|
name: vcr
|
|
365
365
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -433,6 +433,7 @@ files:
|
|
|
433
433
|
- ".rspec"
|
|
434
434
|
- ".ruby-version"
|
|
435
435
|
- ".standard.yml"
|
|
436
|
+
- CLAUDE.md
|
|
436
437
|
- Elexis_Artikelstamm_v003.xsd
|
|
437
438
|
- Elexis_Artikelstamm_v5.xsd
|
|
438
439
|
- Gemfile
|
|
@@ -466,6 +467,7 @@ files:
|
|
|
466
467
|
- lib/oddb2xml/compressor.rb
|
|
467
468
|
- lib/oddb2xml/downloader.rb
|
|
468
469
|
- lib/oddb2xml/extractor.rb
|
|
470
|
+
- lib/oddb2xml/fhir_support.rb
|
|
469
471
|
- lib/oddb2xml/options.rb
|
|
470
472
|
- lib/oddb2xml/parslet_compositions.rb
|
|
471
473
|
- lib/oddb2xml/semantic_check.rb
|