oddb2xml 3.0.25 → 3.0.26

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: 1c80413abe4bd24c2c3555aa6eb4407c7464e381275a118c7929c686fe73f494
4
- data.tar.gz: 7bf43ba8cf901d4d2de42d0e21e95bf2e25e4f1a88360b5cc4451427c52adccb
3
+ metadata.gz: 3b77fe7dba0df2c76d396dd0509fc77363d6efa5ee2a70580f8068d01f1aa508
4
+ data.tar.gz: 471b43cc817114af1b09362a224dc1b969e69eb9f1e6c9cb7975cec30bf2b04f
5
5
  SHA512:
6
- metadata.gz: b20195b2d9b1d6ddd0e51b7e8062f71e2bc8c2c7b2d74c817b058c1c56f18ef203b119a39cf80e55310bd9c6d06204f15bb3c0e7d2d988837967fa1381a10735
7
- data.tar.gz: 26b6d78eddd658b8dbed3949b0df277639636a66b8ed86a3a54f9478a19dfbdc81874b88afdafc3221114a41f59f7184c271206f14b6c1f5135f93b895ef3fa3
6
+ metadata.gz: e5317c541ea869c09bc1bd0ea5b2be5c6aa1be6923777f7daa3c419edf03036ba72cc432ef1b06db1215acb979bac27386311cea3d56fee31e9c39631caf20fd
7
+ data.tar.gz: 4dac43d20ae5652b2b3651470492a68dbf9ac6d843ebef98bc6f81f242338a6b4ed339526a7ac2c0d49662641054559a14529cec13ccf8e1b5bada50428fbe0e
data/.gitignore CHANGED
@@ -32,4 +32,6 @@ tmp
32
32
  /epha_interactions.csv
33
33
  /missing_in_refdata.txt
34
34
  /oddb2xml_files_nonpharma.xls
35
- /oddb_calc.csv
35
+ /oddb_calc.csv
36
+ # Python bytecode cache (scripts/visitor_stats.py)
37
+ __pycache__/
data/CLAUDE.md CHANGED
@@ -47,13 +47,13 @@ The system follows a **download → extract → build → compress** pipeline:
47
47
 
48
48
  6. **Compressor** (`lib/oddb2xml/compressor.rb`) — Optional ZIP/TAR.GZ output compression.
49
49
 
50
- 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-export-latest-{de,fr,it}.ndjson`) from `epl.bag.admin.ch` 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.
50
+ 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-export-latest-{de,fr,it}.ndjson`) from `epl.bag.admin.ch` 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).
51
51
 
52
52
  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.
53
53
 
54
54
  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.
55
55
 
56
- 10. **Weleda / Kapitel-70 SL recovery** (`lib/oddb2xml/weleda_sl.rb`, 3.0.21 onwards) — Recovers the SL flag and public price for chapter-70 complementary medicines that are **missing from the FHIR feed** (the partial-replacement gap left by the dead chapter_70_hack, issue #118/#121). Many are magistral Weleda preparations with a `7611916…` trade GTIN that arrive only via ZurRose — with no SL flag and a blanked Publikumspreis (issue #117). `WeledaSL.load` joins two CSVs (downloaded at runtime from `github.com/zdavatz/oddb2xml_files` via `WeledaDownloader` / `BagSlGroupPricesDownloader`, bundled fallback copies under `data/`): `weleda_arzneimittel.csv` (GTIN → `abgabekategorie` SL flag + `csl` = **Pharma-Gruppen-Code**) and `bag_sl_group_prices.csv` (Pharma-Gruppen-Code → public price). The price table is extracted **offline** from the BAG SL definition PDF *"Homoeopathica, Anthroposophica, Allergene"* via `tools/generate_bag_sl_group_prices.rb` (uses system `pdftotext`; **no runtime PDF gem** — `pdf-reader`'s `afm` dep now needs Ruby ≥ 3.2, which would break the gem's Ruby floor). The join is **GTIN → csl → price**, honouring an `N x <code>` package multiplier (price = N × group price). Produces `gtin => {sl:, price:, csl:, abgabe:}` (SL rows only; ~515 priced on the live feed). **WALA products (3.0.22 onwards):** a third runtime CSV `wala_arzneimittel.csv` (GTIN prefix `7640187…`, `WalaDownloader`, bundled fallback) is merged into the same map via `WeledaSL.build_wala_map`. Its layout differs: `;`-separated with a BOM, no `/ SL` column (a row is SL when it carries a `CSL-Code` = Kapitel-70.01 group code), and the public **package** price is given inline in the `CSL 70.01.` column — **already multiplied for the pack size** (the multiplier appears only in the galenic-form text, e.g. `Solutio ad inj. 10 x 1 ml`), so it is taken **verbatim** rather than re-joined against `bag_sl_group_prices.csv` (which holds the per-unit price and would yield 1/10 of the package price for ~120 multi-unit packs). 320 WALA SL products on the live file; Weleda wins on the (unlikely) GTIN collision. `Builder#build_artikelstamm` consumes it (CLI sets `builder.weleda_sl` only for `--artikelstamm`): for any GTIN **absent from the FHIR NDJSON** it emits `<SL_ENTRY>true</SL_ENTRY>` and `<PPUB>` from the BAG group price, mirroring the old chapter-70 behaviour (`PHARMATYPE "P"`). **The FHIR/ZurRose price always wins** — the group price only fills a gap; a zeroed ZurRose `"0.00"` pub price is treated as absent so the gap-fill can apply. Match is **by GTIN only** (no pharmacode); the Swissmedic dispensing category is untouched (still from `Swissmedic_Packungen.xlsx`). The Artikelstamm output gets `<SL_ENTRY>` + `<PPUB>`; for the `-e`/`--extended` and `-b`/`--firstbase` product feeds the BAG public price is also added to `oddb_article.xml` as an `<ARTPRI><PTYP>BAGPUB</PTYP>` entry (the raw, often-blanked `ZURROSEPUB` is preserved alongside it) — `build_article`, gated by the CLI loading `weleda_sl` when `extended || firstbase || artikelstamm`. See GitHub issue #121.
56
+ 10. **Weleda / Kapitel-70 SL recovery** (`lib/oddb2xml/weleda_sl.rb`, 3.0.21 onwards) — Recovers the SL flag and public price for chapter-70 complementary medicines that are **missing from the FHIR feed** (the partial-replacement gap left by the dead chapter_70_hack, issue #118/#121). Many are magistral Weleda preparations with a `7611916…` trade GTIN that arrive only via ZurRose — with no SL flag and a blanked Publikumspreis (issue #117). `WeledaSL.load` joins two CSVs (downloaded at runtime from `github.com/zdavatz/oddb2xml_files` via `WeledaDownloader` / `BagSlGroupPricesDownloader`, bundled fallback copies under `data/`): `weleda_arzneimittel.csv` (GTIN → `abgabekategorie` SL flag + `csl` = **Pharma-Gruppen-Code**) and `bag_sl_group_prices.csv` (Pharma-Gruppen-Code → public price). `weleda_arzneimittel.csv` is **regenerated** by the Rust tool `weleda_scraper/` in the `oddb2xml_files` repo (`scraper --update weleda`, prompts for the medical.weleda.ch `PHPSESSID` cookie — never stored): it walks the paginated Arzneimittel-Verzeichnis listing + per-product detail pages and rewrites the CSV with exactly the currently-listed products (delisted rows dropped), preserving the on-disk format (column order, UTF-8, CRLF, quote-when-necessary, sorted by `id`) so oddb2xml reads it unchanged. The price table is extracted **offline** from the BAG SL definition PDF *"Homoeopathica, Anthroposophica, Allergene"* via `tools/generate_bag_sl_group_prices.rb` (uses system `pdftotext`; **no runtime PDF gem** — `pdf-reader`'s `afm` dep now needs Ruby ≥ 3.2, which would break the gem's Ruby floor). The join is **GTIN → csl → price**, honouring an `N x <code>` package multiplier (price = N × group price). Produces `gtin => {sl:, price:, csl:, abgabe:}` (SL rows only; ~515 priced on the live feed). **WALA products (3.0.22 onwards):** a third runtime CSV `wala_arzneimittel.csv` (GTIN prefix `7640187…`, `WalaDownloader`, bundled fallback) is merged into the same map via `WeledaSL.build_wala_map`. Its layout differs: `;`-separated with a BOM, no `/ SL` column (a row is SL when it carries a `CSL-Code` = Kapitel-70.01 group code), and the public **package** price is given inline in the `CSL 70.01.` column — **already multiplied for the pack size** (the multiplier appears only in the galenic-form text, e.g. `Solutio ad inj. 10 x 1 ml`), so it is taken **verbatim** rather than re-joined against `bag_sl_group_prices.csv` (which holds the per-unit price and would yield 1/10 of the package price for ~120 multi-unit packs). 320 WALA SL products on the live file; Weleda wins on the (unlikely) GTIN collision. `Builder#build_artikelstamm` consumes it (CLI sets `builder.weleda_sl` only for `--artikelstamm`): for any GTIN **absent from the FHIR NDJSON** it emits `<SL_ENTRY>true</SL_ENTRY>` and `<PPUB>` from the BAG group price, mirroring the old chapter-70 behaviour (`PHARMATYPE "P"`). **The FHIR/ZurRose price always wins** — the group price only fills a gap; a zeroed ZurRose `"0.00"` pub price is treated as absent so the gap-fill can apply. Match is **by GTIN only** (no pharmacode); the Swissmedic dispensing category is untouched (still from `Swissmedic_Packungen.xlsx`). The Artikelstamm output gets `<SL_ENTRY>` + `<PPUB>`; for the `-e`/`--extended` and `-b`/`--firstbase` product feeds the BAG public price is also added to `oddb_article.xml` as an `<ARTPRI><PTYP>BAGPUB</PTYP>` entry (the raw, often-blanked `ZURROSEPUB` is preserved alongside it) — `build_article`, gated by the CLI loading `weleda_sl` when `extended || firstbase || artikelstamm`. See GitHub issue #121.
57
57
 
58
58
  ### Key data identifiers
59
59
  - **GTIN/EAN13**: Primary article identifier (13-digit barcode)
@@ -64,6 +64,19 @@ The system follows a **download → extract → build → compress** pipeline:
64
64
  ### Static data overrides
65
65
  YAML files in `data/` provide manual overrides and mappings: `article_overrides.yaml`, `product_overrides.yaml`, `gtin2ignore.yaml`, `gal_forms.yaml`, `gal_groups.yaml`.
66
66
 
67
+ ## Deployment (`scripts/`) — the mediupdatexml.oddb.org download site
68
+
69
+ These scripts run the public download server at `https://mediupdatexml.oddb.org` (Apache on this host) and are **not** part of the gem itself.
70
+
71
+ - **`run_oddb2xml.sh`** — nightly build driver (cron: `0 1 * * * zdavatz`). Downloads the upstream sources **once**, then builds the `-b`/firstbase feed at price increments `45/50/55` plus `default` (no increment) into `$OUT_DIR` (`/home/zdavatz/oddb2xml`, one subdir each). The shared `downloads/` cache and transient zip live in `$BUILD_DIR` (`<OUT_DIR>-build`), **outside** `$OUT_DIR` so the transfer never uploads the multi-hundred-MB cache. Final step ("2b") regenerates the landing page. Each `oddb2xml` invocation is wrapped in `run_with_retry` (default **3 attempts, 120 s apart**, tunable via `ODDB2XML_RETRIES`/`ODDB2XML_RETRY_DELAY`): a transient upstream download failure (e.g. Swissmedic resetting the connection, `Errno::ECONNRESET`) previously aborted the whole `set -e` run 14 s in and left the feeds a day stale, so it now retries before giving up; a genuine repeated failure still stops the run.
72
+ - **`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.
73
+ - **`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`).
74
+ - **`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. It exports `RBENV_VERSION=3.4.5` + the rbenv-shims PATH to match the nightly cron (the repo `.ruby-version` pins an uninstalled Ruby).
75
+ - **`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.
76
+ - **`setup_mediupdatexml_web.sh`** — one-time root setup of the Apache vhost + initial page.
77
+
78
+ Only the scripts are git-tracked; the generated `index.html`/`logo.svg` and the `downloads/` cache are not.
79
+
67
80
  ## Testing
68
81
 
69
82
  - Framework: RSpec with flexmock (mocking), webmock + VCR (HTTP recording/playback)
@@ -0,0 +1,559 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!-- Copyright (c) 2026 MEDEVIT. All rights reserved. This program and the
3
+ accompanying materials are made available under the terms of the Eclipse
4
+ Public License v1.0 which accompanies this distribution, and is available
5
+ at http://www.eclipse.org/legal/epl-v10.html v002
6
+ Contributors: MEDEVIT <office@medevit.at> - initial API and implementation -->
7
+ <!-- oddb2xml note: this is the official Elexis_Artikelstamm_v6 schema extended
8
+ with the optional Italian-language elements (DSCRI on PRODUCT/LIMITATION/ITEM
9
+ and DOSAGE_FORMI on ITEM) that oddb2xml has emitted historically. These are
10
+ oddb2xml extensions and are NOT part of the canonical MEDEVIT v6 schema. -->
11
+ <xs:schema targetNamespace="http://elexis.ch/Elexis_Artikelstamm_v6"
12
+ elementFormDefault="qualified" attributeFormDefault="unqualified"
13
+ version="6" id="Elexis_Artikelstamm_v006" xmlns="http://elexis.ch/Elexis_Artikelstamm_v6" xmlns:xs="http://www.w3.org/2001/XMLSchema">
14
+ <xs:element name="ARTIKELSTAMM">
15
+ <xs:annotation>
16
+ <xs:documentation xml:lang="EN">Information on medicaments
17
+ </xs:documentation>
18
+ </xs:annotation>
19
+ <xs:complexType>
20
+ <xs:sequence>
21
+ <xs:element name="PRODUCTS" minOccurs="1"
22
+ maxOccurs="1">
23
+ <xs:complexType>
24
+ <xs:sequence>
25
+ <xs:element name="PRODUCT" minOccurs="0"
26
+ maxOccurs="unbounded">
27
+ <xs:complexType>
28
+ <xs:sequence>
29
+ <xs:element name="PRODNO"
30
+ type="PRODNOType" minOccurs="1" maxOccurs="1">
31
+ </xs:element>
32
+ <xs:element name="SALECD" type="SALECDType" minOccurs="1" maxOccurs="1">
33
+ </xs:element>
34
+ <xs:element name="DSCR"
35
+ type="DSCRType" minOccurs="1" maxOccurs="1">
36
+ </xs:element>
37
+ <xs:element name="DSCRF"
38
+ type="DSCRType" minOccurs="1" maxOccurs="1">
39
+ </xs:element>
40
+ <xs:element name="DSCRI"
41
+ type="DSCRType" minOccurs="0" maxOccurs="1">
42
+ <xs:annotation>
43
+ <xs:documentation>oddb2xml extension: Italian description</xs:documentation>
44
+ </xs:annotation>
45
+ </xs:element>
46
+ <xs:element name="ATC"
47
+ nillable="false" maxOccurs="1" minOccurs="0">
48
+ <xs:annotation>
49
+ <xs:documentation
50
+ xml:lang="EN">
51
+ ATC Code beinhaltet
52
+ Information ob Item
53
+ ein Impfstoff ist.
54
+ Dies ist der Fall
55
+ wenn der ATC Code
56
+ mit J07 startet.
57
+ </xs:documentation>
58
+ </xs:annotation>
59
+ <xs:simpleType>
60
+ <xs:restriction
61
+ base="xs:string">
62
+ <xs:maxLength
63
+ value="8" />
64
+ </xs:restriction>
65
+ </xs:simpleType>
66
+ </xs:element>
67
+ <xs:element name="LIMNAMEBAG"
68
+ type="xs:string" minOccurs="0" maxOccurs="1">
69
+ </xs:element>
70
+
71
+ <xs:element name="SUBSTANCE"
72
+ type="xs:string" minOccurs="0" maxOccurs="1">
73
+ </xs:element>
74
+ <xs:element name="SUBSTANCEF"
75
+ type="xs:string" minOccurs="0" maxOccurs="1">
76
+ </xs:element>
77
+ </xs:sequence>
78
+ </xs:complexType>
79
+ <xs:unique name="uniqueProdno">
80
+ <xs:selector xpath="PRODNO" />
81
+ <xs:field xpath="." />
82
+ </xs:unique>
83
+ </xs:element>
84
+ </xs:sequence>
85
+ </xs:complexType>
86
+ </xs:element>
87
+ <xs:element name="LIMITATIONS" minOccurs="1"
88
+ maxOccurs="1">
89
+ <xs:complexType>
90
+ <xs:sequence>
91
+ <xs:element name="LIMITATION" minOccurs="0"
92
+ maxOccurs="unbounded">
93
+ <xs:complexType>
94
+ <xs:sequence>
95
+ <xs:element name="LIMNAMEBAG"
96
+ type="xs:string" minOccurs="1" maxOccurs="1">
97
+ </xs:element>
98
+ <xs:element name="DSCR"
99
+ type="DSCRType" minOccurs="1" maxOccurs="1">
100
+ </xs:element>
101
+ <xs:element name="DSCRF"
102
+ type="DSCRType" minOccurs="1" maxOccurs="1">
103
+ </xs:element>
104
+ <xs:element name="DSCRI"
105
+ type="DSCRType" minOccurs="0" maxOccurs="1">
106
+ <xs:annotation>
107
+ <xs:documentation>oddb2xml extension: Italian description</xs:documentation>
108
+ </xs:annotation>
109
+ </xs:element>
110
+ <xs:element
111
+ name="LIMITATION_PTS" type="xs:int" maxOccurs="1"
112
+ minOccurs="0">
113
+ <xs:annotation>
114
+ <xs:documentation>
115
+ Limitationspunkte
116
+ </xs:documentation>
117
+ </xs:annotation>
118
+ </xs:element>
119
+ </xs:sequence>
120
+ </xs:complexType>
121
+ </xs:element>
122
+ </xs:sequence>
123
+ </xs:complexType>
124
+ </xs:element>
125
+
126
+ <xs:element name="ITEMS" minOccurs="1" maxOccurs="1">
127
+ <xs:complexType>
128
+ <xs:sequence>
129
+ <xs:element name="ITEM" minOccurs="0"
130
+ maxOccurs="unbounded">
131
+ <xs:complexType>
132
+ <xs:annotation>
133
+ <xs:documentation>
134
+ Packungsgröße verrechnet,
135
+ also Anzahl der beinhalteten
136
+ Elemente (bspw. 100
137
+ Tabletten)
138
+ </xs:documentation>
139
+ </xs:annotation>
140
+ <xs:sequence>
141
+
142
+ <xs:element name="GTIN"
143
+ nillable="false" minOccurs="0" maxOccurs="1">
144
+ <xs:annotation>
145
+ <xs:documentation
146
+ xml:lang="EN">
147
+ Reference number
148
+ (GTIN = Global trade
149
+ item number)
150
+ </xs:documentation>
151
+ </xs:annotation>
152
+ <xs:simpleType>
153
+ <xs:restriction
154
+ base="xs:string">
155
+
156
+ </xs:restriction>
157
+ </xs:simpleType>
158
+ </xs:element>
159
+ <xs:element name="PHAR"
160
+ nillable="false" minOccurs="0" maxOccurs="1">
161
+ <xs:annotation>
162
+ <xs:documentation
163
+ xml:lang="EN">
164
+ Pharmacode
165
+ </xs:documentation>
166
+ </xs:annotation>
167
+ <xs:simpleType>
168
+ <xs:restriction
169
+ base="xs:integer" />
170
+ </xs:simpleType>
171
+ </xs:element>
172
+
173
+
174
+ <xs:element name="SALECD" type="SALECDType" minOccurs="1" maxOccurs="1">
175
+ <xs:annotation>
176
+ <xs:documentation></xs:documentation>
177
+ </xs:annotation>
178
+ </xs:element>
179
+ <xs:element name="DSCR"
180
+ nillable="false" type="DSCRType" minOccurs="1"
181
+ maxOccurs="1">
182
+ <xs:annotation>
183
+ <xs:documentation
184
+ xml:lang="EN">
185
+ Product description,
186
+ e.g. Adalat retard
187
+ Tabletten 20 mg
188
+ </xs:documentation>
189
+ </xs:annotation>
190
+ </xs:element>
191
+ <xs:element name="DSCRF"
192
+ type="xs:string" minOccurs="1" maxOccurs="1">
193
+ </xs:element>
194
+ <xs:element name="DSCRI"
195
+ type="xs:string" minOccurs="0" maxOccurs="1">
196
+ <xs:annotation>
197
+ <xs:documentation>oddb2xml extension: Italian description</xs:documentation>
198
+ </xs:annotation>
199
+ </xs:element>
200
+ <xs:element name="COMP"
201
+ minOccurs="0">
202
+ <xs:annotation>
203
+ <xs:documentation
204
+ xml:lang="EN">
205
+ Manufacturer
206
+ </xs:documentation>
207
+ </xs:annotation>
208
+ <xs:complexType>
209
+ <xs:sequence>
210
+ <xs:element
211
+ name="NAME" minOccurs="0" maxOccurs="1">
212
+ <xs:annotation>
213
+ <xs:documentation>
214
+ CompanyName
215
+ </xs:documentation>
216
+ </xs:annotation>
217
+ <xs:simpleType>
218
+ <xs:restriction
219
+ base="xs:string">
220
+ <xs:maxLength
221
+ value="101" />
222
+ </xs:restriction>
223
+ </xs:simpleType>
224
+ </xs:element>
225
+ <xs:element
226
+ name="GLN" nillable="false" minOccurs="0" maxOccurs="1">
227
+ <xs:annotation>
228
+ <xs:documentation
229
+ xml:lang="EN">
230
+ Company
231
+ GLN
232
+ </xs:documentation>
233
+ </xs:annotation>
234
+ <xs:simpleType>
235
+ <xs:restriction
236
+ base="xs:string">
237
+ <xs:maxLength
238
+ value="13" />
239
+ </xs:restriction>
240
+ </xs:simpleType>
241
+ </xs:element>
242
+ </xs:sequence>
243
+ </xs:complexType>
244
+ </xs:element>
245
+ <xs:element name="PEXF"
246
+ type="xs:double" minOccurs="0" maxOccurs="1">
247
+ <xs:annotation>
248
+ <xs:documentation>
249
+ Exfactorypreis in
250
+ Franken und Rappen
251
+ (exkl. MWSt)
252
+ </xs:documentation>
253
+ </xs:annotation>
254
+ </xs:element>
255
+ <xs:element name="PPUB"
256
+ type="xs:double" minOccurs="0" maxOccurs="1">
257
+ <xs:annotation>
258
+ <xs:documentation>
259
+ Publikumspreis in
260
+ Franken und Rappen
261
+ (inkl.MWSt)
262
+ </xs:documentation>
263
+ </xs:annotation>
264
+ </xs:element>
265
+ <xs:element name="PKG_SIZE"
266
+ type="xs:int" maxOccurs="1" minOccurs="0">
267
+ </xs:element>
268
+ <xs:element name="MEASURE" type="xs:string" minOccurs="0" maxOccurs="1">
269
+ <xs:annotation>
270
+ <xs:documentation>
271
+ Measurement Unit,
272
+ e.g. Pills or
273
+ milliliters
274
+ </xs:documentation>
275
+ </xs:annotation>
276
+ </xs:element>
277
+ <xs:element
278
+ name="MEASUREF" type="xs:string" minOccurs="0"
279
+ maxOccurs="1">
280
+ </xs:element>
281
+ <xs:element name="DOSAGE_FORM" type="xs:string" minOccurs="0" maxOccurs="1">
282
+ <xs:annotation>
283
+ <xs:documentation>
284
+ Die Darreichungsform
285
+ dieses Items. zB
286
+ Tablette(n) oder
287
+ Spritze(n)
288
+ </xs:documentation>
289
+ </xs:annotation>
290
+ </xs:element>
291
+ <xs:element name="DOSAGE_FORMF" type="xs:string" minOccurs="0" maxOccurs="1">
292
+ <xs:annotation>
293
+ <xs:documentation>Die Darreichungsform dieses Items. zB Tablette(n) oder Spritze(n) in französicher Sprache
294
+ </xs:documentation>
295
+ </xs:annotation>
296
+ </xs:element>
297
+ <xs:element name="DOSAGE_FORMI" type="xs:string" minOccurs="0" maxOccurs="1">
298
+ <xs:annotation>
299
+ <xs:documentation>oddb2xml extension: Darreichungsform in italienischer Sprache</xs:documentation>
300
+ </xs:annotation>
301
+ </xs:element>
302
+ <xs:element name="SL_ENTRY"
303
+ type="xs:boolean" minOccurs="0" maxOccurs="1">
304
+ <xs:annotation>
305
+ <xs:documentation>
306
+ Item ist in der
307
+ Spezialitätenliste
308
+ (SL) eingetragen
309
+ </xs:documentation>
310
+ </xs:annotation>
311
+ </xs:element>
312
+ <xs:element name="IKSCAT"
313
+ maxOccurs="1" minOccurs="0">
314
+ <xs:annotation>
315
+ <xs:documentation>
316
+ Abgabekategorie
317
+ </xs:documentation>
318
+ </xs:annotation>
319
+
320
+ <xs:simpleType>
321
+ <xs:annotation>
322
+ <xs:documentation>
323
+ Abgabekategorie,
324
+ A-E A:
325
+ verschärft
326
+ rezeptpflichtig
327
+ B:
328
+ Rezeptpflichtig
329
+ C: erhältlich in
330
+ Apotheken ohne
331
+ Rezept D:
332
+ erhältlich in
333
+ Apotheken und
334
+ Drogerien E:
335
+ keine
336
+ Kategorisierung
337
+ </xs:documentation>
338
+ </xs:annotation>
339
+ <xs:restriction
340
+ base="xs:string">
341
+ <xs:enumeration
342
+ value="A">
343
+ </xs:enumeration>
344
+ <xs:enumeration
345
+ value="B">
346
+ </xs:enumeration>
347
+ <xs:enumeration
348
+ value="C">
349
+ </xs:enumeration>
350
+ <xs:enumeration
351
+ value="D">
352
+ </xs:enumeration>
353
+ <xs:enumeration
354
+ value="E">
355
+ </xs:enumeration>
356
+ </xs:restriction>
357
+ </xs:simpleType>
358
+ </xs:element>
359
+ <xs:element name="GENERIC_TYPE"
360
+ maxOccurs="1" minOccurs="0">
361
+
362
+ <xs:simpleType>
363
+ <xs:annotation>
364
+ <xs:documentation>
365
+ O Original G
366
+ Generikum K
367
+ Komplementärprodukt
368
+ </xs:documentation>
369
+ </xs:annotation>
370
+ <xs:restriction
371
+ base="xs:string">
372
+ <xs:enumeration
373
+ value="O">
374
+ </xs:enumeration>
375
+ <xs:enumeration
376
+ value="G">
377
+ </xs:enumeration>
378
+ <xs:enumeration
379
+ value="K">
380
+ </xs:enumeration>
381
+ </xs:restriction>
382
+ </xs:simpleType>
383
+ </xs:element>
384
+ <xs:element name="HAS_GENERIC"
385
+ type="xs:boolean" minOccurs="0" maxOccurs="1">
386
+ <xs:annotation>
387
+ <xs:documentation>
388
+ Generikum zu diesem
389
+ Produkt vorhanden
390
+ ja/nein
391
+ </xs:documentation>
392
+ </xs:annotation>
393
+ </xs:element>
394
+ <xs:element name="LPPV"
395
+ type="xs:boolean" maxOccurs="1" minOccurs="0">
396
+ <xs:annotation>
397
+ <xs:documentation>
398
+ Ist eingetragen in
399
+ Liste
400
+ pharmazeutischer
401
+ Präparate mit
402
+ spezieller
403
+ Verwendung (LPPV)
404
+ </xs:documentation>
405
+ </xs:annotation>
406
+ </xs:element>
407
+
408
+ <xs:element name="DEDUCTIBLE"
409
+ type="xs:int" maxOccurs="1" minOccurs="0">
410
+ <xs:annotation>
411
+ <xs:documentation>
412
+ Selbstbehalt für
413
+ SL-Produkte in
414
+ prozent
415
+ </xs:documentation>
416
+ </xs:annotation>
417
+ </xs:element>
418
+ <xs:element name="NARCOTIC"
419
+ type="xs:boolean" maxOccurs="1" minOccurs="0">
420
+ <xs:annotation>
421
+ <xs:documentation>
422
+ Produkt ist
423
+ Betäubungsmittel
424
+ ja/nein
425
+ </xs:documentation>
426
+ </xs:annotation>
427
+ </xs:element>
428
+ <xs:element name="NARCOTIC_CAS"
429
+ type="xs:string" maxOccurs="1" minOccurs="0">
430
+ <xs:annotation>
431
+ <xs:documentation>
432
+ Wenn
433
+ Betäubungsmittel
434
+ (NARCOTIC == true)
435
+ dann CAS Register
436
+ Nummer des Artikels
437
+ </xs:documentation>
438
+ </xs:annotation>
439
+ </xs:element>
440
+
441
+ <xs:element name="PRODNO"
442
+ minOccurs="0" maxOccurs="1" type="PRODNOType">
443
+ <xs:annotation>
444
+ <xs:documentation>
445
+ Produktnummer des
446
+ Artikels
447
+ </xs:documentation>
448
+ </xs:annotation>
449
+ </xs:element>
450
+
451
+ <xs:element name="ARTSL" minOccurs="0" maxOccurs="1">
452
+ <xs:annotation>
453
+ <xs:documentation>
454
+ Daten aus der BAG Spezialitätenliste (SL)
455
+ </xs:documentation>
456
+ </xs:annotation>
457
+ <xs:complexType>
458
+ <xs:sequence>
459
+ <xs:element name="PM" type="xs:boolean"/>
460
+ <xs:element name="ARTLIMS" minOccurs="1" maxOccurs="1">
461
+ <xs:complexType>
462
+ <xs:sequence>
463
+ <xs:element name="ARTLIM" minOccurs="0"
464
+ maxOccurs="unbounded">
465
+ <xs:complexType>
466
+ <xs:sequence>
467
+ <xs:element name="LIMCD" type="xs:string"/>
468
+ <xs:element name="INDCD" type="xs:string"/>
469
+ <xs:element name="VDAT" type="xs:dateTime" minOccurs="0"/>
470
+ <xs:element name="VTDAT" type="xs:dateTime" minOccurs="0"/>
471
+ </xs:sequence>
472
+ </xs:complexType>
473
+ </xs:element>
474
+ </xs:sequence>
475
+ </xs:complexType>
476
+ </xs:element>
477
+ </xs:sequence>
478
+ </xs:complexType>
479
+ </xs:element>
480
+ </xs:sequence>
481
+
482
+ <xs:attribute name="PHARMATYPE">
483
+ <xs:annotation>
484
+ <xs:documentation>P Pharma, N Non-Pharma
485
+ </xs:documentation>
486
+ </xs:annotation>
487
+ <xs:simpleType>
488
+ <xs:restriction
489
+ base="xs:string">
490
+ <xs:enumeration
491
+ value="N">
492
+ </xs:enumeration>
493
+ <xs:enumeration
494
+ value="P">
495
+ </xs:enumeration>
496
+ </xs:restriction>
497
+ </xs:simpleType>
498
+ </xs:attribute>
499
+ </xs:complexType>
500
+ </xs:element>
501
+ </xs:sequence>
502
+ </xs:complexType>
503
+ </xs:element>
504
+ </xs:sequence>
505
+ <xs:attribute name="CREATION_DATETIME" type="xs:dateTime"
506
+ use="required">
507
+ <xs:annotation>
508
+ <xs:documentation>Erstellungszeitpunkt des QuellDatensatzes (siehe DATA_SOURCE)
509
+ </xs:documentation>
510
+ </xs:annotation>
511
+ </xs:attribute>
512
+
513
+ <xs:attribute name="BUILD_DATETIME" type="xs:dateTime">
514
+ <xs:annotation>
515
+ <xs:documentation>Der Zeitpunkt zu dem dieser Datensatz erstellt wurde.
516
+ </xs:documentation>
517
+ </xs:annotation>
518
+ </xs:attribute>
519
+ <xs:attribute name="DATA_SOURCE" type="DATASOURCEType">
520
+ <xs:annotation>
521
+ <xs:documentation></xs:documentation>
522
+ </xs:annotation></xs:attribute>
523
+ </xs:complexType>
524
+ </xs:element>
525
+
526
+
527
+
528
+
529
+ <xs:simpleType name="PRODNOType">
530
+ <xs:restriction base="xs:string"></xs:restriction>
531
+ </xs:simpleType>
532
+
533
+ <xs:simpleType name="DSCRType">
534
+ <xs:restriction base="xs:string"></xs:restriction>
535
+ </xs:simpleType>
536
+
537
+
538
+
539
+ <xs:simpleType name="DATASOURCEType">
540
+ <xs:annotation>
541
+ <xs:documentation>The data source this Artikelstamm is generated from.
542
+ </xs:documentation>
543
+ </xs:annotation>
544
+ <xs:restriction base="xs:string">
545
+ <xs:enumeration value="oddb2xml"></xs:enumeration>
546
+ <xs:enumeration value="medindex"></xs:enumeration>
547
+ </xs:restriction>
548
+ </xs:simpleType>
549
+
550
+ <xs:simpleType name="SALECDType">
551
+ <xs:annotation>
552
+ <xs:documentation>Bedeutung ist 'A' = Aktiv,'I' = inaktiv == Ausser Handel</xs:documentation>
553
+ </xs:annotation>
554
+ <xs:restriction base="xs:string">
555
+ <xs:enumeration value="I"></xs:enumeration>
556
+ <xs:enumeration value="A"></xs:enumeration>
557
+ </xs:restriction>
558
+ </xs:simpleType>
559
+ </xs:schema>
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- oddb2xml (3.0.25)
4
+ oddb2xml (3.0.26)
5
5
  csv
6
6
  htmlentities
7
7
  httpi