oddb2xml 3.0.34 → 3.0.36

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aba1912f4878121d6d5aa1424b2c1f33f72b1eaceefd21d7b973753209b97c6e
4
- data.tar.gz: 89ba2fffeefae9dffb569cb94be5308b67a7e847813e6de9771d5fee5a626996
3
+ metadata.gz: a85937ce6661b7094f1ca21340b46073ef281dcaf13aa5fe9f319412a0dd2bb1
4
+ data.tar.gz: 9cf9304ad319e03fc8b9d5077a6a90805aa253aff7297740480b09324a9551c7
5
5
  SHA512:
6
- metadata.gz: b9c54d7a0f8cf8189c5703e36aa309c40302b8880a65e552122dc8728d9827f7b15f1ae1c976abab6a342efb6547d31dc44e957ffb8b5b93ef2adb6be7cced99
7
- data.tar.gz: d185bc2f3f9eadd52e618038fbae1b9050aa65ed4e36e0116cba42a68b9948d19a6794e300a131328c4db5a3075f1f1b250c7a43ef7a481129a4aaa3efd59fd4
6
+ metadata.gz: de43402f34d886b009654fc0cd614e999f11c7323757441f5c99442236c727689fd1d0d3c1345f6895fb1eb33bd57e9acb6b587235af1547be7f9bcfb4196ee6
7
+ data.tar.gz: f9e124c449ca9bb9ec23ce90697bf70e05d48681c80fba8408752e746f62d0428b0107356c9bd0e2275128e707abcde3d7666ae7c159b02db85b675f759e018d
@@ -22,12 +22,12 @@ jobs:
22
22
  fail-fast: false
23
23
  matrix:
24
24
  os: [ubuntu]
25
- ruby: ["3.0", "3.1", "3.2"]
25
+ ruby: ["3.2", "3.3"]
26
26
  env:
27
27
  BUNDLE_PATH: ".bundle/gems"
28
28
  BUNDLE_WITHOUT: "debugger"
29
29
  steps:
30
- - uses: actions/checkout@v2
30
+ - uses: actions/checkout@v4
31
31
  - uses: ruby/setup-ruby@v1
32
32
  with:
33
33
  ruby-version: ${{ matrix.ruby }}
data/CLAUDE.md CHANGED
@@ -24,7 +24,7 @@ The system follows a **download → extract → build → compress** pipeline:
24
24
 
25
25
  7. **FHIR support** (`lib/oddb2xml/fhir_support.rb`) — Self-contained module providing `FhirDownloader` and FHIR NDJSON parsing. Activated via `--fhir` (or `--fhir-url=<URL>`). Downloads per-language NDJSON files (`foph-sl-publication-latest-{de,fr,it}.ndjson`) from `epl.bag.admin.ch/static/sl/publication/fhir` (**3.0.32 onwards**; BAG moved the export there on 24.08.2026, announced it the next evening, and oddb2xml followed in 3.0.32; the old `/static/fhir/foph-sl-export-*` answers 404 for `-latest-` while the old dated snapshots remain, so the failure looks like an import that does nothing) to populate French and Italian product names/descriptions. Maps legal status codes `756005022007` and `756005022008` to Swissmedic category D. Reads the BAG **Indikationscode** (`XXXXX.NN`) from the explicit `indicationCode` extension on each `RegulatedAuthorization.indication[].extension[regulatedAuthorization-limitation]` (BAG SL FHIR export >= v2.0.5; handled from 3.0.10). The BAG changelog states the limitation code (`ClinicalUseDefinition.id`) and the indication code are **independent** fields, so the older derivation — combining each indication CUD's `.NN` id-suffix with the reimbursement RA's `FOPHDossierNumber` — is kept only as a fallback for feeds lacking the extension. Exposed as `item[:indication_codes]` and per-package `:indication_codes` (each entry a `{code:, cud_id:, text:}` hash, where `cud_id` is the `limitationIndication` CUD reference used to resolve the text). From 3.0.7 onwards, `Builder#build_product` emits one `<INDICATION_CODE code="XXXXX.NN" cud_id="DRUG.NN">limitation text</INDICATION_CODE>` child per indication on every `<PRD>` in `oddb_product.xml`; live feed numbers: 539 products / 1,293 codes / 100 % with non-empty indication text. Mandatory on prescriptions/invoices for SL price-model drugs from 2026-07-01 — see issue [#113](https://github.com/zdavatz/oddb2xml/issues/113). **Limitation texts** (3.0.8 onwards): the `regulatedAuthorization-limitation` extension has no inline `limitationText` in the live BAG feed — it carries a `limitationIndication` reference to a `ClinicalUseDefinition` whose `indication.diseaseSymptomProcedure.concept.text` is the actual text. The parser stores the ref as `cud_ref` on each Limitation, `Bundle#cud_text_by_id` resolves DE, and `merge_language` propagates FR/IT from the per-language NDJSON files via the same CUD id. Coverage on the live feed jumped from 0 / 9'108 to 9'108 / 9'108 (issue [#116](https://github.com/zdavatz/oddb2xml/issues/116)). **Limitation code / LIMNAMEBAG** (3.0.12 onwards): FHIR has no native BAG limitation code (LIMCD), so `create_limitations_for_package` sets `LimitationCode = cud_ref` (the `limitationIndication` CUD id) instead of `""`. Without this, every FHIR limitation shared an empty `:code`; `Builder#build_artikelstamm` groups its `<LIMITATIONS>` section by code, so all of them collapsed into a single `<LIMITATION>` with an empty `<LIMNAMEBAG>` and only one text survived. Using the CUD id as the key makes each distinct limitation emit and be referenced from its `<PRODUCT>`. The downstream `bin/check_artikelstamm` (`semantic_check.rb`) also crashed on the lone-element output because Ox `:hash_no_attrs` collapses a one-child section into a Hash (and an empty one into nil) — `SemanticCheckXML#get_items` now normalises every section to an Array. **v6 Artikelstamm / per-article INDC (3.0.26 onwards):** `--artikelstamm` now emits the **Elexis Artikelstamm v6** format (namespace `http://elexis.ch/Elexis_Artikelstamm_v6`, file `artikelstamm_DDMMYYYY_v6.xml`/`.csv`, validated against the bundled `Elexis_Artikelstamm_v6.xsd`) — replacing v5. The new piece is a per-`<ITEM>` `<ARTSL>` block carrying the BAG Indikationscodes (issue [#113](https://github.com/zdavatz/oddb2xml/issues/113)): `<PM>true</PM>` plus one `<ARTLIM>` per limitation with `<LIMCD>` (= `cud_ref`, the BAG limitation code), `<INDCD>` (the `XXXXX.NN` indication code from the `indicationCode` extension), and `<VDAT>`/`<VTDAT>` (period start/end). To feed it, `create_limitations_for_package` now also carries `IndicationCode` (→ per-package `:indcd`) and `ValidThruDate` (→ `:vtdate`) on each limitation; `Builder#append_artsl`/`elexis_datetime` emit one `<ARTLIM>` per limitation that has a non-empty `:indcd` (so non-price-model items get no `<ARTSL>`). `PM` is always `true` here because the indication code is required only for SL price-model drugs, which is exactly the set of items that reach this block. The bundled `Elexis_Artikelstamm_v6.xsd` is the canonical MEDEVIT schema extended with oddb2xml's historical Italian elements (`DSCRI` on PRODUCT/LIMITATION/ITEM, `DOSAGE_FORMI` on ITEM) so the output still validates. The legacy `--no-fhir` path emits no `<ARTSL>` (no FHIR limitations). **Per-article INDC in the `-e`/`-b` feeds (3.0.27 onwards):** the same indication codes are also emitted per `<ART>` in `oddb_article.xml` via `Builder#append_indication_codes` — one `<INDICATION_CODE code="XXXXX.NN" cud_id="DRUG.NN" limcd="DRUG.NN" vdat=… vtdat=…>limitation text</INDICATION_CODE>` per limitation that carries an `:indcd` (sourced from the article's `pac[:limitations]`). This reuses the flat `<INDICATION_CODE>` element already emitted on `<PRD>` in `oddb_product.xml` (since 3.0.7), enriched with the limitation code and validity dates so it carries the full `<ARTSL>` payload. The bundled `oddb2xml.xsd` now defines `<INDICATION_CODE>` (simpleContent + `code`/`cud_id`/`limcd`/`vdat`/`vtdat` attributes) and references it from both `<ART>` and `<PRD>` — the `<PRD>` reference had been missing since the 3.0.7 addition, so the FHIR product feed only validated once this was added. **Legacy v5 opt-in (3.0.28 onwards):** `--artikelstamm-v5` additionally emits the older Artikelstamm v5 file alongside the v6 one (for consumers not yet migrated). It implies `--artikelstamm`; `Options.parse` sets `@opts[:artikelstamm] = true`. `build_artikelstamm` takes a `version:` keyword (default `6`) that drives the namespace (`Elexis_Artikelstamm_v#{version}`) and CSV/XML filename suffix; `build_artikelstamm_v5` calls it with `version: 5`. The only content difference is that the v5 path skips `append_artsl` (the v5 schema has no `<ARTSL>` element). The CLI `files` map adds an `:artikelstamm_v5` entry (→ `build_artikelstamm_v5` via the `to_xml` subject dispatch) and validates it against the bundled `Elexis_Artikelstamm_v5.xsd`. Both files come from one build — the `@prepared` guard means the shared download/prepare phase runs once. **Selbstbehalt / costShare (3.0.31 onwards):** the `reimbursementSL` extension's `costShare` valueInteger is the BAG Selbstbehalt percentage; it maps to the legacy `FlagSB` semantics of the old BAG `Preparations.xml` — `40` → `item[:deductible] = "Y"` (raised Selbstbehalt: `<SLOPLUS>1</SLOPLUS>` in `oddb_article.xml`, `<DEDUCTIBLE>40</DEDUCTIBLE>` in the Artikelstamm), `10` → `"N"` (ordinary Selbstbehalt: `SLOPLUS` 2 / `DEDUCTIBLE` 10), `20` → `deductible20 = "Y"` (transitional rate, issue #81). Until 3.0.31 the mapping was inverted (`10` → `"Y"`), so **every** SL article claimed a 40 % Selbstbehalt. On the live feed only 385 of 10'388 packages (222 preparations) carry `costShare` 40; the value never mixes within one preparation, so keeping the flag at sequence level is safe.
26
26
 
27
- 8. **Refdata cleanup** (`lib/oddb2xml/refdata_cleanup.rb`) — Compensates for known data-quality issues in upstream Refdata.Articles.xml before they reach the output. Each fix is guarded by a Swissmedic-side heuristic (e.g. comma in `substance_swissmedic` to distinguish mono products from real combinations). Currently fixes (a) the doubled-dose template bug (`X mg / X mg / Stk`, `fix_double_dose`, guarded by `single_substance?`); (b) the spelled-out German galenic form `Retardtabletten` → house-style abbreviation `Ret Tabl` (`normalize_galenic_form` / `GALENIC_NORMALISATIONS`, issue #112 case #13, e.g. RINVOQ — a narrow word-boundary substitution that leaves legitimate brand suffixes like `TRAMAL retard` and Mepha's `Lactab` untouched); and (c) dose info Refdata dropped from `<FullName>`, sourced from the Swissmedic composition string `pack[:composition_swissmedic]` — `fix_missing_combo_dose` (#6, appends a combination's 2nd component strength), `fix_missing_dose` (#4, inserts a mono product's missing strength before the pack count), `fix_missing_volume` (#7, appends an injectable's per-pen volume); and (d) 50-char-truncation repairs — `fix_truncated_metoject` (#1, rebuilds METOJECT Autoinjektor names from the intact `<brand> Autoinjektor <dose>/<vol>` prefix + Swissmedic `size`, localised DE/FR/IT) and `fix_truncated_volume_unit` (#3, restores the cut `ml` of the VERACTIV Vitamin D3 drops). The (c) and (d) fixes are scoped to explicit IKSNR allow-lists (`COMBO_DOSE_IKSNR`/`MISSING_DOSE_IKSNR`/`MISSING_VOLUME_IKSNR`/`METOJECT_IKSNR`/`VERACTIV_VITD3_IKSNR`): a dry run proved a blanket heuristic mis-fires on hundreds of legitimate names (sodium counter-ion doses, strength-less phyto/powder products, concentration names like `CIMZIA 200 mg/ml`), so only catalogued registrations are touched — add an IKSNR to grow coverage. Called from `Builder#apply_refdata_description_cleanups!` at the start of `prepare_articles`. See GitHub issue #112 for the catalogue.
27
+ 8. **Refdata cleanup** (`lib/oddb2xml/refdata_cleanup.rb`) — Compensates for known data-quality issues in upstream Refdata.Articles.xml before they reach the output. Each fix is guarded by a Swissmedic-side heuristic (e.g. comma in `substance_swissmedic` to distinguish mono products from real combinations). Currently fixes (a) the doubled-dose template bug (`X mg / X mg / Stk`, `fix_double_dose`, guarded by `single_substance?`); (b) the spelled-out German galenic form `Retardtabletten` → house-style abbreviation `Ret Tabl` (`normalize_galenic_form` / `GALENIC_NORMALISATIONS`, issue #112 case #13, e.g. RINVOQ — a narrow word-boundary substitution that leaves legitimate brand suffixes like `TRAMAL retard` and Mepha's `Lactab` untouched); and (c) dose info Refdata dropped from `<FullName>`, sourced from the Swissmedic composition string `pack[:composition_swissmedic]` — `fix_missing_combo_dose` (#6, appends a combination's 2nd component strength), `fix_missing_dose` (#4, inserts a mono product's missing strength before the pack count), `fix_missing_volume` (#7, appends an injectable's per-pen volume); and (d) 50-char-truncation repairs — `fix_truncated_metoject` (#1, rebuilds METOJECT Autoinjektor names from the intact `<brand> Autoinjektor <dose>/<vol>` prefix + Swissmedic `size`, localised DE/FR/IT) and `fix_truncated_volume_unit` (#3, restores the cut `ml` of the VERACTIV Vitamin D3 drops). The (c) and (d) fixes are scoped to explicit IKSNR allow-lists (`COMBO_DOSE_IKSNR`/`MISSING_DOSE_IKSNR`/`MISSING_VOLUME_IKSNR`/`METOJECT_IKSNR`/`VERACTIV_VITD3_IKSNR`): a dry run proved a blanket heuristic mis-fires on hundreds of legitimate names (sodium counter-ion doses, strength-less phyto/powder products, concentration names like `CIMZIA 200 mg/ml`), so only catalogued registrations are touched — add an IKSNR to grow coverage. (e) **German-only wrong pack volume** (`fix_german_volume_from_fr_it`, 28.09.2026, reported by zollsoft): Refdata's DE `<FullName>` for PRIVIGEN 400 ml (7680583140053) reads `… 200 ml Dfl` while FR/IT say 400 ml, and FERINJECT/VIYANA/FERYXA 1000 mg say `10 ml` in DE against `20 ml` in FR/IT. This one is deliberately **not** IKSNR-scoped: it is a cross-language check inside Refdata's own record (FR and IT must agree on exactly one standalone `<n> ml`, DE must carry exactly one and differ; concentrations like `mg/ml` or `1000 mg/20ml` are excluded by the look-behind), and a dry run over all 16'967 articles of the live file changed exactly those four, each confirmed by ZurRose `transfer.dat` and, for PRIVIGEN, the BAG FHIR name. Runs per item after the per-language pipeline, logged as `German pack volume disagreeing with FR/IT`. Called from `Builder#apply_refdata_description_cleanups!` at the start of `prepare_articles`. See GitHub issue #112 for the catalogue.
28
28
 
29
29
  9. **Chapter-70 hack** (`lib/oddb2xml/chapter_70_hack.rb`) — Legacy scraper for the SL "Komplementärarzneimittel" products (homeopathic/anthroposophic/phytotherapeutic), called only from `Builder#build_artikelstamm`. **Deprecated / non-FHIR only (3.0.11 onwards):** the source page `varia_De.htm` was rebuilt as a JavaScript SPA with no static data table, so the scraper now returns nothing there. These products + limitations now come through the FHIR feed (SL classification `20. KOMPLEMENTÄRARZNEIMITTEL`, 221 products on the live DE feed with real GTINs and limitation texts), so `build_artikelstamm` **skips the scraper entirely when `@options[:fhir]`** (the default for `--artikelstamm` since 3.0.9). In `--no-fhir` mode the scraper degrades gracefully (skips non-row/`<script>` nodes and empty tables, warns, returns `[]`) instead of raising `NoMethodError`. See GitHub issue #118.
30
30
 
@@ -50,7 +50,7 @@ These scripts run the public download server at `https://mediupdatexml.oddb.org`
50
50
  - **`generate_index_html.sh DOCROOT [FIRSTBASE_CSV]`** — single source of truth for the landing page. Writes `index.html` + a self-contained `logo.svg` **atomically** (temp + `mv`, so either owner — root from setup, `zdavatz` from cron — can refresh it). Computes live counts: PHARMA = `<SMNO>` count in `default/oddb_article.xml`, NONPHARMA = firstbase CSV rows − 1, total ART = `<ART ` count. Also runs **`visitor_stats.py`** and embeds its graph. Re-run standalone any time (it only reads already-built files); a separate cron line refreshes it **hourly** (`5 * * * * zdavatz`) so counts + graph stay current between nightly builds.
51
51
  - **`visitor_stats.py LOG_GLOB CACHE_DIR [DAYS]`** — emits the visitors/sessions/region graph as an inline-SVG HTML **fragment** (last `DAYS`, default 14): Besucher = distinct IPs/day, Sitzungen = 30-min-inactivity sessions per `(IP, User-Agent)`, plus a top-6 country breakdown by IP. Bots are filtered by User-Agent. Region lookup is **fully self-contained** — pure Python stdlib + the free **DB-IP country-lite CSV** (CC-BY, no licence key) cached in the build `downloads/` dir and refreshed monthly; **no apt package, no gem, no system GeoIP DB**. Prints nothing (page degrades to omitting the section) when the Apache log is unreadable or empty. Reading `/var/log/apache2` requires the cron user to be in the **`adm`** group (`sudo usermod -aG adm zdavatz`).
52
52
  - **`swissmedic_watch.sh`** — outage/block auto-recovery (cron: `*/30 * * * * zdavatz`). Since the Swissmedic platform migration (~2026-06-23, now a Swisscom-operated gateway), `www.swissmedic.ch` intermittently resets this host's automated connections **after the TLS handshake** (TCP RST), which aborts `run_oddb2xml.sh` under `set -e` and leaves the feeds stale (the block is host/IP- and client-fingerprint-sensitive: a real browser works, `curl`/`wget`/Ruby get reset, while other admin.ch hosts answer fine — so it is a WAF/bot rule, not an outage). The watcher polls Swissmedic with **oddb2xml's own client** (a Ruby `open-uri` canary on `listen_neu.html`); while blocked it is a silent no-op, and the moment it gets HTTP 200 it launches **one** build and emails. It fires **at most once per day** (stamp in `$STATE_DIR`, default `<OUT_DIR>-watch`, kept **outside** the wiped `$BUILD_DIR`), and skips when a build is already running or today's `default/oddb_article.xml` is already fresh. **PATH assembly (2026-07-28 onwards):** cron hands the watcher a minimal PATH, so it builds one explicitly — `/usr/local/bin` (where a root `gem install` under Debian's system Ruby puts the binstub) + the user gem bin dir (`$(ruby -e 'print Gem.user_dir')/bin`, where an *unprivileged* `gem install` silently lands instead, because `/var/lib/gems` is not writable) + rbenv shims **only when they exist** (the pre-2026-07 box; `RBENV_VERSION=3.4.5` is exported only in that case). The previous hardcoded rbenv-only PATH carried no `/usr/local/bin`, so the first watcher-triggered build on the rebuilt server died `oddb2xml: Kommando nicht gefunden` (exit 127) on all three retries before aborting. `run_oddb2xml.sh` step 1 extends PATH the same way right after its `gem install`, for the same reason.
53
- - **`transfer.sh`** — optional hand-off (scp) of `$OUT_DIR` to the HIN host; `SCP_DEST` is required-but-unset until the HIN host is known. **Obsolete, not merely unwired (issue [#127](https://github.com/zdavatz/oddb2xml/issues/127)):** it is not in `/etc/cron.d/mediupdatexml` and `SCP_DEST` still has no default — and it should stay that way, because **HIN no longer hosts a copy at all**. Their page `www.hin.ch/de/services/mediupdate-xml.cfm#section_2` links *this* site's URLs directly (`default/`, `45/`, `50/`, `55/`, `aips2sqlite/`) and mentions `download.hin.ch` exactly **zero** times, so there is nothing left to scp. The trap is that the unfed legacy path `https://download.hin.ch/download/oddb2xml/` **still answers HTTP 200**, serving the build of **22.06.2026** (`GENERATED_BY="oddb2xml 3.0.25"`, `Last-Modified: Sun, 21 Jun 2026 23:01:34 GMT`) — the whole directory carries that one date. It looks authoritative, it 404s nothing, and that is how issue #127 was filed against us for data that was current here all along. When someone reports stale data, **check which host they fetched from before touching the build**: `mediupdatexml.oddb.org` is the source of truth. HIN has been asked to 301 or retire the old path. `README.md` and `QA.md` now point at `mediupdatexml.oddb.org` throughout. For twenty days one link on HIN's page was genuinely broken and it was **ours**: `/aips2sqlite/oddb2xml_swissmedic_sequences.csv` answered 404 — **not** because the server rebuild lost it, but because aips2sqlite hit **the same Refdata barcode bug as issue #122**: `ean_code.length()` on the missing `<DataCarrierIdentifier>` threw a `NullPointerException` out of four copied loops, and `generate_aips_fi` deleted the published CSV *before* regenerating it, so under `set -e` each crash left nothing rather than yesterday's file. Fixed 02.09.2026 in aips2sqlite `ad2fc58` (all four loops skip barcode-less articles; the CSV is now published by `mv` only after a run that passed a row-count check). **When Refdata breaks one of the two projects, check the other** — they parse the same file with the same assumption.
53
+ - **`transfer.sh`** — optional hand-off (scp) of `$OUT_DIR` to the HIN host; `SCP_DEST` is required-but-unset until the HIN host is known. **Obsolete, not merely unwired (issue [#127](https://github.com/zdavatz/oddb2xml/issues/127)):** it is not in `/etc/cron.d/mediupdatexml` and `SCP_DEST` still has no default — and it should stay that way, because **HIN no longer hosts a copy at all**. Their page `www.hin.ch/de/services/mediupdate-xml.cfm#section_2` links *this* site's URLs directly (`default/`, `45/`, `50/`, `55/`, `aips2sqlite/`) and mentions `download.hin.ch` exactly **zero** times, so there is nothing left to scp. The trap is that the unfed legacy path `https://download.hin.ch/download/oddb2xml/` **still answers HTTP 200**, serving the build of **22.06.2026** (`GENERATED_BY="oddb2xml 3.0.25"`, `Last-Modified: Sun, 21 Jun 2026 23:01:34 GMT`) — the whole directory carries that one date. It looks authoritative, it 404s nothing, and that is how issue #127 was filed against us for data that was current here all along. When someone reports stale data, **check which host they fetched from before touching the build**: `mediupdatexml.oddb.org` is the source of truth. HIN has been asked to 301 or retire the old path. `README.md` and `QA.md` now point at `mediupdatexml.oddb.org` throughout. For twenty days one link on HIN's page was genuinely broken and it was **ours**: `/aips2sqlite/oddb2xml_swissmedic_sequences.csv` answered 404 — **not** because the server rebuild lost it, but because aips2sqlite hit **the same Refdata barcode bug as issue #122**: `ean_code.length()` on the missing `<DataCarrierIdentifier>` threw a `NullPointerException` out of four copied loops, and `generate_aips_fi` deleted the published CSV *before* regenerating it, so under `set -e` each crash left nothing rather than yesterday's file. Fixed 02.09.2026 in aips2sqlite `ad2fc58` (all four loops skip barcode-less articles; the CSV is now published by `mv` only after a run that passed a row-count check). **When Refdata breaks one of the two projects, check the other** — they parse the same file with the same assumption. Second aips2sqlite outage of the same shape, 16.–22.09.2026: swissdrg.org retired the file id behind the DE *Fallpauschalenkatalog* download and served an HTML page *"Ungültige Datei"* with **HTTP 200** for it; `FileUtils.copyURLToFile` wrote the page over `swiss_drg_de_xlsx.xlsx`, `XSSFWorkbook` threw `POIXMLException: Package should contain a content type part` (a RuntimeException past the `IOException`-only catch), and the `--lang=de --xml` step died nightly for a week — `amiko_db_full_idx_de.db` in `jars/output/` shrank to 16 kB while the FR DB and the sequences CSV (built by the later `--nodown` steps, which never touch that file) stayed at 15.09. A downstream user (Swiss Medical Record) reported the identical trace from their own jar. Fixed 22.09.2026 in aips2sqlite `8cf81a6`: new file id `1444/1029` (same 6.0/2017 catalogue), zip-magic check with last-good keep on download, and `extractSwissDRGInfo` skips with a `SKIPPED` warning instead of aborting — the Swiss DRG Zusatzentgelte are optional enrichment, **a missing side file must never abort the run**. Nothing on the host tells you when the 04:30 job dies (no MTA), so the `jars/output/` timestamps are the monitor.
54
54
  - **`setup_new_server.sh`** (2026-07-28 onwards) — one-command rebuild of a bare Debian host into this download site, written after **HIN deleted the previous server**: everything that had accumulated by hand on that box, so a rebuild is not a day of archaeology. Root, idempotent. Installs the packages (`apache2` + `certbot`; **`ruby-full`** — Debian 13 ships Ruby 3.3, matching the repo `.ruby-version`, so the new host has **no rbenv**; `cron`, absent on a minimal image; `unzip`/`zip` for the ZurRose transfer and the build zip; `poppler-utils` for `pdftotext` in `tools/generate_bag_sl_group_prices.rb`; nokogiri/ffi build headers), adds `$RUN_USER` to **`adm`** (so `visitor_stats.py` may read `/var/log/apache2`), sets the home dir to **711** (so `www-data` can traverse to `$OUT_DIR` without listing it), `gem install oddb2xml`, creates the five directories (`$OUT_DIR`, `-build`, `-state`, `-watch` — the last two deliberately outside the nightly-wiped `-build` — and `$GET_TRANSFER_DIR`, symlinking the tracked `get_transfer.sh` into it), writes **`/etc/cron.d/mediupdatexml`** (the whole schedule in one re-creatable file rather than the old box's per-user crontab: `get_transfer` 00:30, nightly build 01:00, rust2xml Artikelstamm 03:00, aips2sqlite Fachinfos 04:30, landing page hourly at `:05`, Swissmedic watcher every 30 min) plus a logrotate rule for the cron logs, then hands over to `setup_aips2sqlite.sh` and `setup_mediupdatexml_web.sh`. Env: `RUN_USER`, `OUT_DIR`, `SKIP_WEB=1`, `SKIP_GEM=1`, `SKIP_AIPS=1`. **Deliberately out of scope** (gone with the old server, restored separately): an MTA for the cron `MAILTO`.
55
55
  - **`setup_mediupdatexml_web.sh`** — one-time root setup of the Apache vhost + initial page. Skips the Let's Encrypt step with a hint when the domain does not yet resolve in **public** DNS (checked via Cloudflare DoH, not the local resolver, which may hold a stale or negative cache).
56
56
  - **`setup_aips2sqlite.sh`** (2026-08-01 onwards) — provisions the **aips2sqlite** half of the site, restored after the server rebuild. The landing page links a whole `/aips2sqlite/` section (Fachinformationen as XML, the AmiKo `.db` files, `oddb2xml_swissmedic_sequences.csv`, `atc_codes_used_set.txt`); `setup_mediupdatexml_web.sh` writes the Apache alias `/aips2sqlite` → `<checkout>/jars/output`, but on the rebuilt host neither the JRE nor that directory existed, so **every link answered 403** — Apache falls back to the default `<Directory /> Require all denied` when the aliased path does not exist, so a *missing* target reads as *forbidden*, not *not found*. Root, idempotent: installs **`openjdk-21-jre-headless`** (the repo ships a prebuilt fat jar `jars/aips2sqlite.jar`, so a JRE suffices — no JDK/Gradle unless the jar is rebuilt; the only AWT user, `BarCode.java`/barcode4j, is off the Fachinfo path), clones the checkout if missing, creates `jars/output` + `jars/downloads` owned by `$RUN_USER`, and appends the 04:30 cron entry to `/etc/cron.d/mediupdatexml` if not already there (`SKIP_CRON=1` when called from `setup_new_server.sh`, which writes that line itself). Generating the data is a separate build-user step: `aips2sqlite/scripts/generate_aips_fi` (~1 h; `--lang=de --xml`, then `--lang=fr --xml --nodown`, then `--nodown --smsequence`, renaming `swiss_medic_sequences.csv` → `oddb2xml_swissmedic_sequences.csv`). `REFDATA_API_KEY` (developer.refdata.ch) is **optional** for this path: the Refdata Partner SOAP download it gates is consumed only by the Takeda partner export, and its failure is caught and logged, so the FI/sequences output is complete without it.
@@ -63,4 +63,6 @@ Only the scripts are git-tracked; the generated `index.html`/`logo.svg` and the
63
63
  - Framework: RSpec with flexmock (mocking), webmock + VCR (HTTP recording/playback)
64
64
  - Test fixtures: `spec/data/` (sample files), `spec/fixtures/vcr_cassettes/` (recorded HTTP responses)
65
65
  - `spec/spec_helper.rb` defines test constants (GTINs) and configures VCR to avoid real HTTP calls during tests
66
- - CI runs on Ruby 3.0, 3.1, 3.2
66
+ - CI runs on Ruby 3.2 and 3.3 (`.github/workflows/ruby.yml`); 3.0/3.1 cannot resolve the lockfile since `public_suffix` 7 (Feb 2026) and are EOL
67
+ - **Never call `Kernel#exit` inside `lib/`.** Until 02.09.2026 `Builder#prepare_substances` did (`exit 2` when an `-e`/`--artikelstamm` build had no substances). Under rspec that killed the *process* mid-run: the summary still printed, reading e.g. `1193 examples, 0 failures`, but the count was a random fraction of the ~1640 examples and the exit status was 2 — CI had been red on every run for over a year while local runs looked green to anyone reading the summary line instead of `$?`. The trigger was the June-2026 FHIR default: `-e` implies `--fhir`, `epl.bag.admin.ch` was never stubbed, `FhirExtractor added 0 items`, empty substances, exit. Builder now raises `Oddb2xml::NoSubstancesError` (`util.rb`) and only `bin/oddb2xml` turns that into exit status 2, so production keeps its contract and rspec sees an ordinary failure. `mock_downloads` stubs the three NDJSON URLs with `spec/data/fhir/cyramza.ndjson` (one bundle — enough to stay non-empty, not enough for price assertions), and the four CLI specs that assert on SL prices run with `--no-fhir` against the stubbed `Preparations.xml`, which is what they were written for. **When judging a run, read the exit status and the example count, not the failure count.**
68
+ - **Dependabot security jobs (fixed 02.09.2026, `b9e1f09`):** the "bundler in /. for savon/json" runs had failed for months. Two lessons. (a) Dependabot resolves the lockfile against the **lowest** Ruby the gemspec allows — with `required_ruby_version >= 2.5.0` it could not pick json ≥ 2.8 (needs 2.7), so a stale floor silently blocks every security update; the floor is `>= 3.2.0` now. (b) A gem that is only `require`d but never called still carries its advisories: `savon` (two HIGH, GHSA Savon::Model evaluates WSDL operation names) had been dead since the SwissReg migration `4c65a9c` (March 2026), pinned `~> 2.12.0` so the fix 2.17.2 was unreachable. Removing it also removed akami/gyoku/nori/wasabi/httpi/socksify — and exposed that `HTTPI.log = false` in `Downloader#initialize` was loaded only through savon (305 NameErrors), so that line and `httpi` went too. Before bumping a pinned gem, `grep -rn "Savon::"`-style for actual use first. **The published 3.0.34 gem still declares `savon` and `httpi`**, so every `gem install oddb2xml` — including the nightly's step 1 — pulls the vulnerable savon 2.12.1 until 3.0.35 is released. The same sweep later removed `multi_json` and the direct `rubyntlm` pin plus the two `net/ntlm` requires in `downloader.rb` (zero references, SOAP-era leftovers); `rubyntlm` itself stays in the lockfile as a transitive dependency of `mechanize`, which carries the same `>= 0.6.3` CVE floor.
data/Gemfile.lock CHANGED
@@ -1,13 +1,11 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- oddb2xml (3.0.34)
4
+ oddb2xml (3.0.35)
5
5
  csv
6
6
  htmlentities
7
- httpi
8
7
  mechanize (>= 2.8.5)
9
8
  minitar
10
- multi_json
11
9
  nokogiri (>= 1.18.10)
12
10
  optimist
13
11
  ox
@@ -15,9 +13,7 @@ PATH
15
13
  rack (>= 3.1.20)
16
14
  rexml (>= 3.3.9)
17
15
  rubyXL (~> 3.4.0)
18
- rubyntlm (>= 0.6.3)
19
16
  rubyzip (~> 3.0.1)
20
- savon (~> 2.12.0)
21
17
  sax-machine
22
18
  spreadsheet
23
19
  standardrb
@@ -29,14 +25,9 @@ GEM
29
25
  specs:
30
26
  addressable (2.9.0)
31
27
  public_suffix (>= 2.0.2, < 8.0)
32
- akami (1.3.3)
33
- base64
34
- gyoku (>= 0.4.0)
35
- nokogiri
36
28
  ast (2.4.3)
37
29
  base64 (0.3.0)
38
30
  bigdecimal (4.0.1)
39
- builder (3.3.0)
40
31
  byebug (13.0.0)
41
32
  reline (>= 0.6.0)
42
33
  coderay (1.1.3)
@@ -48,18 +39,12 @@ GEM
48
39
  diff-lcs (1.6.2)
49
40
  domain_name (0.6.20240107)
50
41
  flexmock (3.0.2)
51
- gyoku (1.4.0)
52
- builder (>= 2.1.2)
53
- rexml (~> 3.0)
54
42
  hashdiff (1.2.1)
55
43
  htmlentities (4.4.2)
56
44
  http-cookie (1.1.0)
57
45
  domain_name (~> 0.5)
58
- httpi (2.5.0)
59
- rack
60
- socksify
61
46
  io-console (0.8.2)
62
- json (2.19.5)
47
+ json (2.21.2)
63
48
  language_server-protocol (3.17.0.5)
64
49
  lint_roller (1.1.0)
65
50
  logger (1.7.0)
@@ -83,7 +68,6 @@ GEM
83
68
  mime-types-data (3.2026.0203)
84
69
  mini_portile2 (2.8.9)
85
70
  minitar (1.1.0)
86
- multi_json (1.19.1)
87
71
  mutex_m (0.3.0)
88
72
  net-http-digest_auth (1.4.1)
89
73
  net-http-persistent (4.0.8)
@@ -94,8 +78,6 @@ GEM
94
78
  racc (~> 1.4)
95
79
  nokogiri (1.19.4-arm64-darwin)
96
80
  racc (~> 1.4)
97
- nori (2.7.1)
98
- bigdecimal
99
81
  optimist (3.2.1)
100
82
  ox (2.14.23)
101
83
  bigdecimal (>= 3.0)
@@ -165,16 +147,7 @@ GEM
165
147
  rubyntlm (0.6.5)
166
148
  base64
167
149
  rubyzip (3.0.2)
168
- savon (2.12.1)
169
- akami (~> 1.2)
170
- builder (>= 2.1.2)
171
- gyoku (~> 1.2)
172
- httpi (~> 2.3)
173
- nokogiri (>= 1.8.1)
174
- nori (~> 2.4)
175
- wasabi (~> 3.4)
176
150
  sax-machine (1.3.2)
177
- socksify (1.8.1)
178
151
  spreadsheet (1.3.4)
179
152
  bigdecimal
180
153
  logger
@@ -198,10 +171,6 @@ GEM
198
171
  unicode-emoji (~> 4.1)
199
172
  unicode-emoji (4.2.0)
200
173
  vcr (6.4.0)
201
- wasabi (3.7.0)
202
- addressable
203
- httpi (~> 2.0)
204
- nokogiri (>= 1.4.2)
205
174
  webmock (3.26.1)
206
175
  addressable (>= 2.8.0)
207
176
  crack (>= 0.3.2)
data/History.txt CHANGED
@@ -1,6 +1,16 @@
1
+ === 3.0.36 / 28.09.2026
2
+ * Bugfix (Refdata cleanup): correct the German pack volume when Refdata's German name disagrees with its own French and Italian names. Reported by zollsoft for PRIVIGEN 10% 400 ml (GTIN 7680583140053), whose German description in oddb_product.xml and oddb_article.xml read "200 ml" -- the German name of the 200 ml pack -- while French, Italian, ZurRose and the BAG FHIR feed all say 400 ml. The same Refdata defect affects FERINJECT, VIYANA and FERYXA 1000 mg (German "10 ml", correct 20 ml). The fix changes exactly these four articles on the September 2026 Refdata file; concentrations such as mg/ml are never touched. Suite: 1646 examples, 0 failures.
3
+ * Builder creates WORK_DIR before writing missing_in_refdata.txt and the Artikelstamm CSV (order-dependent spec failure, seed 17155).
4
+ * Dependencies: multi_json and the direct rubyntlm pin removed (SOAP-era leftovers, no references); rubyntlm stays as a dependency of mechanize.
5
+
6
+ === 3.0.35 / 02.09.2026
7
+ * Security: drop the savon dependency (two HIGH advisories, GHSA "Savon::Model evaluates WSDL operation names as Ruby source"). It had not been used since the SwissReg migration in 3.0.19 (commit 4c65a9c, March 2026) -- only a require remained -- but the gemspec pin ~> 2.12.0 kept every installer on the vulnerable 2.12.1 and blocked Dependabot's fix to 2.17.2. Removing it also drops akami, gyoku, nori, wasabi, httpi and socksify from the dependency tree; the one leftover reference, HTTPI.log = false in Downloader#initialize, had only ever been loaded through savon and is gone too. json is raised to 2.21.2 (LOW advisory: generator heap buffer overflow when streaming to an IO). 3.0.34 and earlier still declare savon and httpi, so `gem install oddb2xml` on those versions pulls savon 2.12.1 -- upgrade.
8
+ * required_ruby_version is now >= 3.2 (was >= 2.5.0). Ruby 3.0 and 3.1 are end-of-life and have not been able to resolve the lockfile since public_suffix 7 (February 2026); the stale floor also made Dependabot resolve against Ruby 2.5, which is why its json update could never succeed. CI runs on 3.2 and 3.3.
9
+ * Builder no longer calls Kernel#exit when an -e/--extended or --artikelstamm build ends up with no substances; it raises Oddb2xml::NoSubstancesError and bin/oddb2xml turns that into exit status 2 with a message on stderr. The observable behaviour of the command line is unchanged. Inside the spec suite the exit used to kill the rspec process at a random point with a summary line reading "0 failures", which is why the 3.0.34 note claimed a green suite at 1193 examples: that run had been cut short. The suite now runs to completion -- 1639 examples, 0 failures, exit 0 -- on both CI Rubies. The FHIR NDJSON URLs are stubbed in the specs, the four CLI specs asserting on SL prices run with --no-fhir against the BAG Preparations.xml fixture, and the Weleda Kapitel-70 record in spec/data/transfer.dat carries a valid VAT code (2, not 4).
10
+
1
11
  === 3.0.34 / 02.09.2026
2
12
  * Bugfix (WeledaSL): pin the encoding of the three runtime CSVs so the Kapitel-70 SL recovery cannot disappear without a trace. WeledaSL.source passed the downloaded content on in whatever encoding the downloader produced; when that is ASCII-8BIT -- any response served without a charset -- the first comparison against a UTF-8 literal raises Encoding::CompatibilityError, and WeledaSL.load rescues every error into an empty Hash. The whole Weleda/WALA recovery (~835 SL products, issue #121) then vanished silently: no <SL_ENTRY>, no BAG group price, and a build that still reported success. All three lists carry non-ASCII (umlauts in the product names, the "-" in the group-price table), so the live path was one charset-less response away from this; it has not happened yet only because raw.githubusercontent.com serves UTF-8. The encoding is now forced in source, as RoggerNames#parse already did, and the bundled fallback is read with an explicit encoding.
3
- * This was also the last failing spec: artikelstamm_spec.rb:86, the issue #121 regression test, failed because @weleda_sl was empty under webmock and the item was emitted as PHARMATYPE="N" without SL_ENTRY/PPUB. `rspec` is green again -- 1193 examples, 0 failures, 16 pending. Closes GitHub issue #104.
13
+ * This was also the last failing spec: artikelstamm_spec.rb:86, the issue #121 regression test, failed because @weleda_sl was empty under webmock and the item was emitted as PHARMATYPE="N" without SL_ENTRY/PPUB. That example passes now. (This entry originally claimed the whole suite was green at "1193 examples, 0 failures" -- it was not: the count was a truncated run, killed by a Kernel#exit in Builder with exit status 2, see 3.0.35. GitHub issue #104 was closed on that basis and corrected.)
4
14
 
5
15
  === 3.0.33 / 26.08.2026
6
16
  * Bugfix (Refdata): skip PHARMA articles that carry no barcode instead of losing every single one of them. Refdata started publishing a handful of PHARMA articles without a <DataCarrierIdentifier> in August 2026 -- the Swiss Red Cross blood products under the collective registration 99999 (Erythrozytenkonzentrat & co), which have no GTIN at all. RefdataExtractor#to_hash called .size on that nil, and Cli#download_as rescues the resulting NoMethodError into an Oddb2xml.log call that prints nothing unless --log is given, so all 16'093 Refdata PHARMA articles silently vanished from the feeds while the build still reported success. On mediupdatexml.oddb.org the <SMNO> count in oddb_article.xml fell from ~15'300 to exactly 10'210 and stayed frozen there for twelve days -- the remainder came from the BAG FHIR SL feed -- and then to 50 once the BAG url move (3.0.32) took the FHIR feed away too. Refdata NONPHARMA was never affected, because the product-type check runs before the barcode is read. See GitHub issue #122.
data/README.md CHANGED
@@ -136,8 +136,9 @@ FR
136
136
 
137
137
  ## Supported ruby version
138
138
 
139
- You will need ruby >= 2.5 to work correctly. Current development happens on Ruby 3.3 (`.ruby-version`).
140
- CI runs on Ruby 3.0, 3.1 and 3.2 via GitHub Actions — see the badge above for the latest spec results.
139
+ You will need Ruby >= 3.2. Current development happens on Ruby 3.3 (`.ruby-version`).
140
+ CI runs on Ruby 3.2 and 3.3 via GitHub Actions — see the badge above for the latest spec results.
141
+ Ruby 3.0 and 3.1 dropped out with the dependency update of February 2026 (`public_suffix` 7 needs 3.2), and both are end-of-life.
141
142
 
142
143
 
143
144
  ## XSD files
@@ -456,6 +457,14 @@ Currently active fixes (`lib/oddb2xml/refdata_cleanup.rb`):
456
457
  duplicate token is collapsed to a single occurrence. Real combination
457
458
  products (e.g. PHESGO 600 mg / 600 mg / 10 ml — pertuzumab + trastuzumab)
458
459
  are detected via the comma in `substance_swissmedic` and left untouched.
460
+ * **German pack volume disagreeing with French and Italian** — Refdata
461
+ sometimes carries a wrong volume in the German name only, e.g.
462
+ `PRIVIGEN 10% Inf Lös 200 ml Dfl` for the 400 ml pack (GTIN 7680583140053)
463
+ while the French and Italian names say 400 ml. When the French and Italian
464
+ names agree on a single standalone `<n> ml` and the German one differs, the
465
+ German number is replaced. Concentrations such as `mg/ml` are ignored. On
466
+ the September 2026 file this changes exactly four articles (PRIVIGEN 400 ml,
467
+ FERINJECT, VIYANA and FERYXA 1000 mg 20 ml), all confirmed by ZurRose.
459
468
 
460
469
  The cleanup runs at the start of `prepare_articles` in `Builder` and is
461
470
  idempotent. Each rule is guarded by a Swissmedic-side heuristic so genuine
@@ -569,6 +578,14 @@ they are the reference setup for running oddb2xml unattended.
569
578
  cron entry that regenerates the Fachinformationen. Run as root; idempotent.
570
579
  Without it every `/aips2sqlite/` link answers 403, because Apache denies a
571
580
  path that does not exist.
581
+ The job's output is only as robust as its side files: when one of the
582
+ `/aips2sqlite/` files goes stale or tiny, look at the timestamps and sizes in
583
+ `jars/output/` first (a `amiko_db_full_idx_de.db` of a few kB means the
584
+ `--lang=de` step died) and then at `generate_aips_fi.log` -- twice now an
585
+ upstream file changed shape (Refdata articles without barcode, 08/2026;
586
+ SwissDRG serving an HTML page for a retired xlsx id, 09/2026) and the Java
587
+ side crashed instead of skipping. Both are fixed in aips2sqlite; the rule
588
+ there is that a missing side file costs its data, never the run.
572
589
  * `transfer.sh` — optional scp hand-off of the output tree to the HIN
573
590
  download server. **Obsolete, and deliberately not wired up**: `SCP_DEST` has
574
591
  no default and the script is not in `/etc/cron.d/mediupdatexml`. HIN links
data/bin/oddb2xml CHANGED
@@ -38,6 +38,9 @@ begin
38
38
  ui.run
39
39
  rescue Interrupt
40
40
  exit
41
+ rescue Oddb2xml::NoSubstancesError => e
42
+ warn "oddb2xml: #{e.message}"
43
+ exit 2
41
44
  end
42
45
  diff = (Time.now - start_time).to_i
43
46
  puts "#{Time.now}: #{__LINE__} done. Took #{diff} seconds"
@@ -131,11 +131,17 @@ module Oddb2xml
131
131
  end
132
132
  item[key] = desc if desc != original
133
133
  end
134
+ fixed_de = RefdataCleanup.fix_german_volume_from_fr_it(item[:desc_de], item[:desc_fr], item[:desc_it])
135
+ if fixed_de != item[:desc_de]
136
+ item[:desc_de] = fixed_de
137
+ counts[:de_volume] += 1
138
+ end
134
139
  end
135
140
  labels = {metoject: "truncated METOJECT name", veractiv_vol: "truncated volume unit",
136
141
  double_dose: "double-dose pattern", galenic: "galenic form",
137
142
  combo_dose: "missing combo 2nd dose", missing_dose: "missing strength",
138
- volume: "missing injection volume"}
143
+ volume: "missing injection volume",
144
+ de_volume: "German pack volume disagreeing with FR/IT"}
139
145
  counts.each do |rule, n|
140
146
  Oddb2xml.log("Refdata cleanup: fixed #{labels[rule]} in #{n} description(s)") if n > 0
141
147
  end
@@ -277,7 +283,10 @@ module Oddb2xml
277
283
  @substances.uniq!
278
284
  @substances.sort!
279
285
  Oddb2xml.log("prepare_substances done. Total #{@substances.size} from #{@items.size} items")
280
- exit 2 if (@options[:extended] || @options[:artikelstamm]) && (@substances.size == 0)
286
+ if (@options[:extended] || @options[:artikelstamm]) && @substances.empty?
287
+ raise NoSubstancesError, "prepare_substances: no substances from #{@items.size} items -- " \
288
+ "the SL source (FHIR NDJSON or BAG Preparations.xml) yielded nothing, refusing to write an empty feed"
289
+ end
281
290
  end
282
291
  end
283
292
 
@@ -417,7 +426,9 @@ module Oddb2xml
417
426
  XML_OPTIONS
418
427
  ) {
419
428
  Oddb2xml.log "build_substance #{@substances.size} substances"
420
- exit 2 if (@options[:extended] || @options[:artikelstamm]) && (@substances.size == 0)
429
+ if (@options[:extended] || @options[:artikelstamm]) && @substances.empty?
430
+ raise NoSubstancesError, "build_substance: no substances to write"
431
+ end
421
432
  nbr_records = 0
422
433
  @substances.each_with_index do |sub_name, i|
423
434
  xml.SB("DT" => "") do
@@ -582,6 +593,12 @@ module Oddb2xml
582
593
  ean13_to_product[ean13] = obj
583
594
  obj[:pharmacode] ||= @refdata[ean13][:pharmacode] if @refdata[ean13]
584
595
  }
596
+ # The CLI creates WORK_DIR before building, but Builder is also driven
597
+ # directly (fhir_spec), and then this was the first write into it --
598
+ # Errno::ENOENT whenever the random spec order got here before any CLI
599
+ # example had created the directory. ZurroseExtractor guards its own
600
+ # WORK_DIR write the same way.
601
+ FileUtils.makedirs(WORK_DIR)
585
602
  ausgabe = File.open(File.join(WORK_DIR, "missing_in_refdata.txt"), "w+")
586
603
  size_old = ean13_to_product.size
587
604
  @missing = []
@@ -1651,6 +1668,7 @@ module Oddb2xml
1651
1668
  def build_artikelstamm(version: 6)
1652
1669
  @@emitted_v6_gtins = []
1653
1670
  @artikelstamm_version = version
1671
+ FileUtils.makedirs(WORK_DIR) # same guard as add_missing_products_from_swissmedic
1654
1672
  @csv_file = CSV.open(File.join(WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v#{version}.csv"), "w+")
1655
1673
  @csv_file << ["gtin", "name", "pkg_size", "galenic_form", "price_ex_factory", "price_public", "prodno", "atc_code", "active_substance", "original", "it-code", "sl-liste"]
1656
1674
  @csv_file.sync = true
@@ -1,8 +1,5 @@
1
- require "net/ntlm/version" # needed to avoid error: uninitialized constant Net::NTLM::VERSION
2
- require "rubyntlm"
3
1
  require "mechanize"
4
2
  require "zip"
5
- require "savon"
6
3
  require "open-uri"
7
4
 
8
5
  SKIP_MIGEL_DOWNLOADER = true # https://github.com/zdavatz/oddb2xml_files/raw/master/NON-Pharma.xls
@@ -49,7 +46,6 @@ module Oddb2xml
49
46
  @options = options
50
47
  @url = url
51
48
  @retry_times = 3
52
- HTTPI.log = false # disable httpi warning
53
49
  Oddb2xml.log "Downloader from #{@url} for #{self.class}"
54
50
  init
55
51
  end
@@ -194,5 +194,30 @@ module Oddb2xml
194
194
  return desc unless VERACTIV_VITD3_IKSNR.include?(iksnr_of(no8))
195
195
  desc.sub(/(\d)\s*m\z/, '\1ml')
196
196
  end
197
+
198
+ # Refdata sometimes carries a wrong pack volume in the German name only,
199
+ # while the French and Italian names of the same article agree with each
200
+ # other and with ZurRose/BAG. Seen September 2026 (reported by zollsoft):
201
+ # 7680583140053 DE "PRIVIGEN 10% Inf Lös 200 ml Dfl" FR/IT "400 ml"
202
+ # 7680578510052 DE "FERINJECT … 1000 mg 10 ml …" FR/IT "20 ml"
203
+ # 7680693600058 VIYANA, 7680694120050 FERYXA same as FERINJECT
204
+ # A scan of all 16'971 Refdata articles found exactly these four, all
205
+ # confirmed wrong by ZurRose transfer.dat. The guard is strict: each name
206
+ # must carry exactly one standalone "<n> ml" (a concentration like
207
+ # "mg/ml" or "1000 mg/20ml" is not standalone), FR and IT must agree, and
208
+ # only the number in DE is replaced.
209
+ ML_TOKEN = %r{(?<![/\d.,])(\d+(?:[.,]\d+)?)(\s*ml)\b}i
210
+
211
+ def self.fix_german_volume_from_fr_it(de, fr, it)
212
+ return de if [de, fr, it].any? { |d| d.nil? || d.empty? }
213
+ de_ml = de.scan(ML_TOKEN)
214
+ fr_ml = fr.scan(ML_TOKEN)
215
+ it_ml = it.scan(ML_TOKEN)
216
+ return de unless de_ml.size == 1 && fr_ml.size == 1 && it_ml.size == 1
217
+ want = fr_ml.first.first.tr(",", ".")
218
+ return de unless it_ml.first.first.tr(",", ".") == want
219
+ return de if de_ml.first.first.tr(",", ".") == want
220
+ de.sub(ML_TOKEN) { "#{fr_ml.first.first}#{$2}" }
221
+ end
197
222
  end
198
223
  end
data/lib/oddb2xml/util.rb CHANGED
@@ -7,6 +7,14 @@ module Oddb2xml
7
7
  # Raised when a downloaded archive (zip/xlsx) is empty or truncated, so the
8
8
  # caller can retry the download instead of crashing later in the parser.
9
9
  class IncompleteDownloadError < StandardError; end
10
+ # Raised by Builder when an -e/--extended or --artikelstamm build ends up with
11
+ # no substances at all: the SL source (FHIR NDJSON or BAG Preparations.xml)
12
+ # came back empty, so the feed would be useless. bin/oddb2xml turns it into
13
+ # exit status 2; under RSpec it is an ordinary exception. Builder used to call
14
+ # Kernel#exit here, which killed the host process -- inside rspec that ended
15
+ # the run after a random number of examples with a summary line reading
16
+ # "0 failures", so the suite looked green for months while exiting 2.
17
+ class NoSubstancesError < StandardError; end
10
18
 
11
19
  def self.gen_prodno(iksnr, seqnr)
12
20
  sprintf("%05d", iksnr) + sprintf("%02d", seqnr)
@@ -1,3 +1,3 @@
1
1
  module Oddb2xml
2
- VERSION = "3.0.34"
2
+ VERSION = "3.0.36"
3
3
  end
data/oddb2xml.gemspec CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |spec|
15
15
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
16
16
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
17
17
  spec.require_paths = ["lib"]
18
- spec.required_ruby_version = ">= 2.5.0"
18
+ spec.required_ruby_version = ">= 3.2.0"
19
19
 
20
20
  # We fix the version of the spec to newer versions only in the third position
21
21
  # hoping that these version fix only security/severe bugs
@@ -28,14 +28,10 @@ Gem::Specification.new do |spec|
28
28
  # while Ruby 3.1 (EOL but still in production) can install 1.18.10, the newest 3.1-compatible
29
29
  # release. oddb2xml uses no nokogiri 1.19-only API and no CSS/XSLT/C14N on untrusted input.
30
30
  spec.add_dependency "nokogiri", ">= 1.18.10"
31
- spec.add_dependency "savon" , '~> 2.12.0'
32
31
  spec.add_dependency "spreadsheet" # , '~> 1.0.0'
33
32
  spec.add_dependency "rubyXL", "~> 3.4.0"
34
33
  spec.add_dependency "sax-machine" # , '~> 0.1.0'
35
34
  spec.add_dependency "parslet" # , '~> 1.7.0'
36
- spec.add_dependency "rubyntlm", ">= 0.6.3"
37
- spec.add_dependency "multi_json" # , '>= 0.3.2'
38
- spec.add_dependency "httpi" # , '>= 2.4.1'
39
35
  spec.add_dependency "optimist"
40
36
  spec.add_dependency "xml-simple"
41
37
  spec.add_dependency "ox"
data/spec/builder_spec.rb CHANGED
@@ -451,7 +451,9 @@ def checkProductXml(nbr_record = -1)
451
451
  expect(hirudoid.elements["ATC"].text).to eq("C05BA01") # modified by atc.csv!
452
452
  end
453
453
 
454
- NR_EXTENDED_ARTICLES = 80
454
+ # 81 since 3.0.21: spec/data/transfer.dat gained the Weleda Kapitel-70 record
455
+ # 7611916162404 (issue #121), which -e emits as a ZurRose-only article.
456
+ NR_EXTENDED_ARTICLES = 81
455
457
  NR_SUBSTANCES = 28
456
458
  NR_LIMITATIONS = 15
457
459
 
@@ -685,7 +687,9 @@ describe Oddb2xml::Builder do
685
687
  context "when --append -I 80 -e is given" do
686
688
  before(:all) do
687
689
  common_run_init
688
- options = Oddb2xml::Options.parse("--append -I 80 -e")
690
+ # --no-fhir: the assertions below come from the stubbed BAG Preparations.xml;
691
+ # the FHIR stub in mock_downloads is a single bundle (see spec_helper).
692
+ options = Oddb2xml::Options.parse("--append -I 80 -e --no-fhir")
689
693
  Oddb2xml::Cli.new(options).run
690
694
  # @res = buildr_capture(:stdout){ Oddb2xml::Cli.new(options).run }
691
695
  end
@@ -734,7 +738,9 @@ describe Oddb2xml::Builder do
734
738
  context "when option -e is given" do
735
739
  before(:all) do
736
740
  common_run_init
737
- options = Oddb2xml::Options.parse("-e")
741
+ # --no-fhir: the assertions below come from the stubbed BAG Preparations.xml;
742
+ # the FHIR stub in mock_downloads is a single bundle (see spec_helper).
743
+ options = Oddb2xml::Options.parse("-e --no-fhir")
738
744
  puts options
739
745
  @cli = Oddb2xml::Cli.new(options)
740
746
  if RUN_ALL
@@ -853,7 +859,7 @@ describe Oddb2xml::Builder do
853
859
  dscrds = REXML::XPath.match(doc, "//ART")
854
860
  expect(dscrds.size).to eq(NR_EXTENDED_ARTICLES)
855
861
  expect(REXML::XPath.match(doc, "//PHAR").count { |x| x.text.match("0000000") }).to eq(0) # 0 is not a valid pharmacode
856
- expect(REXML::XPath.match(doc, "//PHAR").count { |x| x.text.match(/\d+/) }).to eq 66
862
+ expect(REXML::XPath.match(doc, "//PHAR").count { |x| x.text.match(/\d+/) }).to eq 67 # 66 + the Weleda record
857
863
  end
858
864
 
859
865
  it "should have a correct NBR_RECORD in oddb_limitation.xml" do
@@ -912,7 +918,9 @@ describe Oddb2xml::Builder do
912
918
  context "testing -e -I 80 option" do
913
919
  before(:all) do
914
920
  common_run_init
915
- options = Oddb2xml::Options.parse("-e -I 80 --log")
921
+ # --no-fhir: the assertions below come from the stubbed BAG Preparations.xml;
922
+ # the FHIR stub in mock_downloads is a single bundle (see spec_helper).
923
+ options = Oddb2xml::Options.parse("-e -I 80 --log --no-fhir")
916
924
  # @res = buildr_capture(:stdout){ Oddb2xml::Cli.new(options).run }
917
925
  @res = Oddb2xml::Cli.new(options).run
918
926
  end
data/spec/cli_spec.rb CHANGED
@@ -184,7 +184,9 @@ describe Oddb2xml::Cli do
184
184
  context "when -e and -f dat option is given" do
185
185
  before(:all) do
186
186
  cleanup_directories_before_run
187
- options = Oddb2xml::Options.parse("-e -f dat")
187
+ # --no-fhir: the assertions below come from the stubbed BAG Preparations.xml;
188
+ # the FHIR stub in mock_downloads is a single bundle (see spec_helper).
189
+ options = Oddb2xml::Options.parse("-e -f dat --no-fhir")
188
190
  @cli = Oddb2xml::Cli.new(options)
189
191
  @cli_output = buildr_capture(:stdout) { @cli.run }
190
192
  end
@@ -72,4 +72,4 @@
72
72
  1130361821SEROCYTOL Kolon Supp 3 Stk 002275003960401B080700076800027800132
73
73
  1131770177VARILRIX Trockensub c Solv (alt) Fertspr 0.5 ml 004768006650101B080800076800058500102
74
74
  1127766692VARILRIX Trockensub c Solv Fertspr 0.5 ml 004767006650101B080800076800058500342
75
- 1111019849Absinthium ethanol Infusum D1 Tropfen 50 ml 002000000000300B080160076119161624044
75
+ 1111019849Absinthium ethanol Infusum D1 Tropfen 50 ml 002000000000300B080160076119161624042
@@ -384,4 +384,47 @@ describe Oddb2xml::Builder do
384
384
  expect(builder.refdata["7680576900046"][:desc_de]).to eq "VERACTIV Vitamin D3 Wild Huile Trp 20'000 U.I. 10ml"
385
385
  end
386
386
  end
387
+
388
+ describe ".fix_german_volume_from_fr_it" do
389
+ let(:fix) { ->(de, fr, it) { Oddb2xml::RefdataCleanup.fix_german_volume_from_fr_it(de, fr, it) } }
390
+
391
+ it "takes the volume from FR/IT when DE disagrees (PRIVIGEN 400 ml, zollsoft 09/2026)" do
392
+ expect(fix.call("PRIVIGEN 10% Inf Lös 200 ml Dfl", "PRIVIGEN 10% sol perf 400 ml flac",
393
+ "PRIVIGEN 10% sol inf 400 ml flac")).to eq "PRIVIGEN 10% Inf Lös 400 ml Dfl"
394
+ end
395
+
396
+ it "fixes FERINJECT 1000 mg 10 ml -> 20 ml" do
397
+ expect(fix.call("FERINJECT Disp Inj/Inf 1000 mg 10 ml 1 Flasche", "FERINJECT disp inj/pf 1000 mg 20 ml 1 flacon",
398
+ "FERINJECT disp inj/inf 1000 mg 20 ml 1 flacone")).to eq "FERINJECT Disp Inj/Inf 1000 mg 20 ml 1 Flasche"
399
+ end
400
+
401
+ it "leaves DE alone when FR and IT disagree with each other" do
402
+ de = "X Inf Lös 200 ml Dfl"
403
+ expect(fix.call(de, "X sol perf 400 ml flac", "X sol inf 300 ml flac")).to eq de
404
+ end
405
+
406
+ it "ignores concentrations such as mg/ml or 1000 mg/20ml" do
407
+ de = "CIMZIA Inj Lös 200 mg/ml Fertspr 2 Stk"
408
+ expect(fix.call(de, "CIMZIA sol inj 200 mg/2ml ser pré 2 pce", "CIMZIA sol inj 200 mg/2ml sir pre 2 pz")).to eq de
409
+ end
410
+
411
+ it "does nothing with more than one volume in a name" do
412
+ de = "Y 5 ml + 10 ml"
413
+ expect(fix.call(de, "Y 5 ml + 20 ml", "Y 5 ml + 20 ml")).to eq de
414
+ end
415
+
416
+ it "does nothing when a language is missing" do
417
+ expect(fix.call("Z 10 ml", "", "Z 20 ml")).to eq "Z 10 ml"
418
+ end
419
+
420
+ it "is applied by Builder#apply_refdata_description_cleanups!" do
421
+ builder = Oddb2xml::Builder.new
422
+ builder.packs = {"58314053" => {}}
423
+ builder.refdata = {"7680583140053" => {ean13: "7680583140053", no8: "58314053",
424
+ desc_de: "PRIVIGEN 10% Inf Lös 200 ml Dfl", desc_fr: "PRIVIGEN 10% sol perf 400 ml flac",
425
+ desc_it: "PRIVIGEN 10% sol inf 400 ml flac"}}
426
+ builder.apply_refdata_description_cleanups!
427
+ expect(builder.refdata["7680583140053"][:desc_de]).to eq "PRIVIGEN 10% Inf Lös 400 ml Dfl"
428
+ end
429
+ end
387
430
  end
data/spec/spec_helper.rb CHANGED
@@ -260,6 +260,15 @@ def mock_downloads
260
260
  "https://raw.githubusercontent.com/zdavatz/oddb2xml_files/master/weleda_arzneimittel.csv" => "weleda_arzneimittel.csv",
261
261
  "https://raw.githubusercontent.com/zdavatz/oddb2xml_files/master/wala_arzneimittel.csv" => "wala_arzneimittel.csv",
262
262
  "https://raw.githubusercontent.com/zdavatz/oddb2xml_files/master/bag_sl_group_prices.csv" => "bag_sl_group_prices.csv",
263
+ # The FHIR NDJSON, default SL source for -e/-b since June 2026. It was never
264
+ # stubbed: webmock refused epl.bag.admin.ch, FhirExtractor added 0 items and
265
+ # Builder called Kernel#exit 2, which killed rspec mid-run with a summary
266
+ # line reading "0 failures". One real bundle keeps the extractor non-empty;
267
+ # specs asserting on SL prices/limitations pass --no-fhir and use
268
+ # Preparations.xml instead.
269
+ "https://epl.bag.admin.ch/static/sl/publication/fhir/foph-sl-publication-latest-de.ndjson" => "fhir/cyramza.ndjson",
270
+ "https://epl.bag.admin.ch/static/sl/publication/fhir/foph-sl-publication-latest-fr.ndjson" => "fhir/cyramza.ndjson",
271
+ "https://epl.bag.admin.ch/static/sl/publication/fhir/foph-sl-publication-latest-it.ndjson" => "fhir/cyramza.ndjson",
263
272
  "http://www.xn--spezialittenliste-yqb.ch/File.axd?file=XMLPublications.zip" => "XMLPublications.zip",
264
273
  "https://www.spezialitaetenliste.ch/File.axd?file=XMLPublications.zip" => "XMLPublications.zip",
265
274
  "https://files.refdata.ch/simis-public-prod/Articles/1.0/Refdata.Articles.zip" => "Refdata.Articles.zip",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oddb2xml
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.34
4
+ version: 3.0.36
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yasuhiro Asaka, Zeno R.R. Davatz, Niklaus Giger
@@ -65,20 +65,6 @@ dependencies:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: 1.18.10
68
- - !ruby/object:Gem::Dependency
69
- name: savon
70
- requirement: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - "~>"
73
- - !ruby/object:Gem::Version
74
- version: 2.12.0
75
- type: :runtime
76
- prerelease: false
77
- version_requirements: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - "~>"
80
- - !ruby/object:Gem::Version
81
- version: 2.12.0
82
68
  - !ruby/object:Gem::Dependency
83
69
  name: spreadsheet
84
70
  requirement: !ruby/object:Gem::Requirement
@@ -135,48 +121,6 @@ dependencies:
135
121
  - - ">="
136
122
  - !ruby/object:Gem::Version
137
123
  version: '0'
138
- - !ruby/object:Gem::Dependency
139
- name: rubyntlm
140
- requirement: !ruby/object:Gem::Requirement
141
- requirements:
142
- - - ">="
143
- - !ruby/object:Gem::Version
144
- version: 0.6.3
145
- type: :runtime
146
- prerelease: false
147
- version_requirements: !ruby/object:Gem::Requirement
148
- requirements:
149
- - - ">="
150
- - !ruby/object:Gem::Version
151
- version: 0.6.3
152
- - !ruby/object:Gem::Dependency
153
- name: multi_json
154
- requirement: !ruby/object:Gem::Requirement
155
- requirements:
156
- - - ">="
157
- - !ruby/object:Gem::Version
158
- version: '0'
159
- type: :runtime
160
- prerelease: false
161
- version_requirements: !ruby/object:Gem::Requirement
162
- requirements:
163
- - - ">="
164
- - !ruby/object:Gem::Version
165
- version: '0'
166
- - !ruby/object:Gem::Dependency
167
- name: httpi
168
- requirement: !ruby/object:Gem::Requirement
169
- requirements:
170
- - - ">="
171
- - !ruby/object:Gem::Version
172
- version: '0'
173
- type: :runtime
174
- prerelease: false
175
- version_requirements: !ruby/object:Gem::Requirement
176
- requirements:
177
- - - ">="
178
- - !ruby/object:Gem::Version
179
- version: '0'
180
124
  - !ruby/object:Gem::Dependency
181
125
  name: optimist
182
126
  requirement: !ruby/object:Gem::Requirement
@@ -604,7 +548,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
604
548
  requirements:
605
549
  - - ">="
606
550
  - !ruby/object:Gem::Version
607
- version: 2.5.0
551
+ version: 3.2.0
608
552
  required_rubygems_version: !ruby/object:Gem::Requirement
609
553
  requirements:
610
554
  - - ">="