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.
data/History.txt CHANGED
@@ -1,3 +1,6 @@
1
+ === 3.0.26 / 29.06.2026
2
+ * New (--artikelstamm): emit the Elexis Artikelstamm v6 format with per-article BAG Indikationscodes (INDC). The output bumps from v5 to v6 -- namespace http://elexis.ch/Elexis_Artikelstamm_v6, file artikelstamm_DDMMYYYY_v6.xml/.csv, validated against the new bundled Elexis_Artikelstamm_v6.xsd -- and every SL price-model <ITEM> now carries an <ARTSL> block: <PM>true</PM> plus one <ARTLIM> per limitation with <LIMCD> (BAG limitation code), <INDCD> (the Indikationscode XXXXX.NN), and the validity dates <VDAT>/<VTDAT>. The indication codes were already read from the BAG SL FHIR feed's indicationCode extension (used since 3.0.7 for oddb_product.xml's <INDICATION_CODE>); they are now also carried per package limitation and grouped onto each article. Mandatory on prescriptions/invoices for SL price-model drugs from 2026-07-01 (issue #113). The v6 XSD is the canonical MEDEVIT schema extended with oddb2xml's historical Italian elements (DSCRI / DOSAGE_FORMI) so the output keeps validating. Consumers must switch from the v5 to the v6 file.
3
+
1
4
  === 3.0.25 / 17.06.2026
2
5
  * Bugfix (-b/--firstbase): the GS1 Switzerland firstbase CSV (id.gs1.ch) is served with a UTF-8 BOM. FirstbaseExtractor read it with encoding "UTF-8", so the BOM glued onto the first header ("Gtin") and row["Gtin"] returned nil for every row — every line was skipped as having an empty GTIN. The result: @firstbase came out empty and all NONPHARMA articles from the firstbase feed were silently missing, so oddb_article.xml shipped only the ~17k Refdata/Swissmedic base instead of the ~190k GS1 set. Now read with encoding "bom|utf-8", which strips the BOM (live file: all 192'807 rows parse).
3
6
 
data/README.md CHANGED
@@ -56,7 +56,7 @@ see `--help`.
56
56
  oddb2xml [option]
57
57
  produced files are found under data
58
58
  -a, --append Additional target nonpharma
59
- -r, --artikelstamm Create Artikelstamm Version 3 and 5 for Elexis >= 3.1
59
+ -r, --artikelstamm Create Artikelstamm Version 6 for Elexis >= 3.1
60
60
  -c, --compress-ext=<s> format F. {tar.gz|zip}
61
61
  -e, --extended pharma, non-pharma plus prices and non-pharma from zurrose.
62
62
  Products without EAN-Code will also be listed.
@@ -337,6 +337,40 @@ drugs from **2026-07-01**; from **2027-01-01** insurers may reject
337
337
  invoices without it. See issue
338
338
  [#113](https://github.com/zdavatz/oddb2xml/issues/113).
339
339
 
340
+ Since 3.0.26 the same codes are also carried per article in the
341
+ **Elexis Artikelstamm v6** output (`--artikelstamm`). Each SL
342
+ price-model `<ITEM>` gains an `<ARTSL>` block with one `<ARTLIM>` per
343
+ limitation:
344
+
345
+ ```xml
346
+ <ITEM PHARMATYPE="P">
347
+ <GTIN>7680543780251</GTIN>
348
+ ...
349
+ <PRODNO>58398</PRODNO>
350
+ <ARTSL>
351
+ <PM>true</PM>
352
+ <ARTLIMS>
353
+ <ARTLIM>
354
+ <LIMCD>MABTHERA.01</LIMCD> <!-- BAG limitation code -->
355
+ <INDCD>17079.01</INDCD> <!-- Indikationscode XXXXX.NN -->
356
+ <VDAT>2023-05-01T00:00:00</VDAT>
357
+ </ARTLIM>
358
+ <ARTLIM>
359
+ <LIMCD>MABTHERA.04</LIMCD>
360
+ <INDCD>17079.03</INDCD>
361
+ <VDAT>2026-05-01T00:00:00</VDAT>
362
+ <VTDAT>2026-06-30T00:00:00</VTDAT>
363
+ </ARTLIM>
364
+ </ARTLIMS>
365
+ </ARTSL>
366
+ </ITEM>
367
+ ```
368
+
369
+ The output bumps from Artikelstamm v5 to **v6** (namespace
370
+ `http://elexis.ch/Elexis_Artikelstamm_v6`, file
371
+ `artikelstamm_DDMMYYYY_v6.xml`, validated against the bundled
372
+ `Elexis_Artikelstamm_v6.xsd`); consumers must switch to the v6 file.
373
+
340
374
  ## Limitation texts in `--fhir` mode
341
375
 
342
376
  In 3.0.8 we fixed empty `<DescriptionDe/Fr/It>` on every `<Limitation>`
@@ -432,6 +466,29 @@ You can also run
432
466
  for your currently open Terminal to download and set the Certificate.
433
467
 
434
468
 
469
+ ## Troubleshooting downloads
470
+
471
+ oddb2xml runs a connectivity precheck and prints an `oddb2xml CONNECTIVITY
472
+ WARNING` listing any source host it cannot reach; set
473
+ `ODDB2XML_SKIP_PROXY_CHECK=1` to silence it.
474
+
475
+ A source host may finish the TLS handshake and then **reset the connection
476
+ before sending any HTTP response** (`Errno::ECONNRESET` / "Connection reset by
477
+ peer"; `wget` reports "Read error (Error in the pull function.) in headers").
478
+ When this happens for only one host while everything else is reachable, and a
479
+ normal browser still opens the site, it is usually an **anti-bot gateway (WAF)
480
+ that blocks by TLS fingerprint or source-IP reputation** rather than an outage
481
+ — a User-Agent change does not help. Mitigations: retry later from the same
482
+ host, run the download from a different egress IP, fetch the affected file with
483
+ a browser-grade client (a TLS-impersonating downloader or a headless browser)
484
+ into `downloads/` and continue with `--skip-download`, or ask the source to
485
+ allow-list the host.
486
+
487
+ The deployment driver `scripts/run_oddb2xml.sh` already retries the whole build
488
+ a few times on such transient failures (`ODDB2XML_RETRIES`,
489
+ `ODDB2XML_RETRY_DELAY`); a persistent per-host block still needs one of the
490
+ mitigations above.
491
+
435
492
  ## Testing
436
493
 
437
494
  * Calling rake spec runs spec tests.
@@ -1562,9 +1562,47 @@ module Oddb2xml
1562
1562
  end
1563
1563
  end
1564
1564
 
1565
+ # Normalise a BAG validity date to an xs:dateTime for <VDAT>/<VTDAT>.
1566
+ # A bare date (YYYY-MM-DD) gets midnight appended (unzoned, still a valid
1567
+ # xs:dateTime); a full timestamp is passed through; blank -> nil.
1568
+ def elexis_datetime(str)
1569
+ s = str.to_s.strip
1570
+ return nil if s.empty?
1571
+ s = "#{s}T00:00:00" if /\A\d{4}-\d{2}-\d{2}\z/.match?(s)
1572
+ s
1573
+ end
1574
+
1575
+ # Emit the v6 <ARTSL> block for one ITEM: one <ARTLIM> per limitation that
1576
+ # carries a BAG Indikationscode (INDCD). Mandatory on prescriptions/invoices
1577
+ # for SL price-model drugs from 2026-07-01 (issue #113). <PM> is "true"
1578
+ # because the BAG indication code is required only for price-model SL drugs,
1579
+ # which is exactly the set of items that reach this block.
1580
+ def append_artsl(xml, limitations)
1581
+ artlims = (limitations || []).select { |lim| lim[:indcd] && !lim[:indcd].to_s.empty? }
1582
+ artlims = artlims.uniq { |lim| [lim[:code], lim[:indcd]] }
1583
+ return if artlims.empty?
1584
+ xml.ARTSL do
1585
+ xml.PM "true"
1586
+ xml.ARTLIMS do
1587
+ artlims.each do |lim|
1588
+ xml.ARTLIM do
1589
+ xml.LIMCD lim[:code].to_s
1590
+ xml.INDCD lim[:indcd].to_s
1591
+ if (vdat = elexis_datetime(lim[:vdate]))
1592
+ xml.VDAT vdat
1593
+ end
1594
+ if (vtdat = elexis_datetime(lim[:vtdate]))
1595
+ xml.VTDAT vtdat
1596
+ end
1597
+ end
1598
+ end
1599
+ end
1600
+ end
1601
+ end
1602
+
1565
1603
  def build_artikelstamm
1566
- @@emitted_v5_gtins = []
1567
- @csv_file = CSV.open(File.join(WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v5.csv"), "w+")
1604
+ @@emitted_v6_gtins = []
1605
+ @csv_file = CSV.open(File.join(WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v6.csv"), "w+")
1568
1606
  @csv_file << ["gtin", "name", "pkg_size", "galenic_form", "price_ex_factory", "price_public", "prodno", "atc_code", "active_substance", "original", "it-code", "sl-liste"]
1569
1607
  @csv_file.sync = true
1570
1608
  variant = "build_artikelstamm"
@@ -1649,10 +1687,10 @@ module Oddb2xml
1649
1687
  end
1650
1688
  end
1651
1689
  info = @calc_items[pkg_gtin]
1652
- if @@emitted_v5_gtins.index(pkg_gtin)
1690
+ if @@emitted_v6_gtins.index(pkg_gtin)
1653
1691
  next
1654
1692
  else
1655
- @@emitted_v5_gtins << pkg_gtin.clone
1693
+ @@emitted_v6_gtins << pkg_gtin.clone
1656
1694
  end
1657
1695
  options = {"PHARMATYPE" => "P"}
1658
1696
  xml.ITEM(options) do
@@ -1771,6 +1809,8 @@ module Oddb2xml
1771
1809
  end
1772
1810
  end
1773
1811
  xml.PRODNO prodno if prodno
1812
+ # v6 <ARTSL>: per-article BAG Indikationscodes (issue #113).
1813
+ append_artsl(xml, package[:limitations])
1774
1814
  @csv_file << [pkg_gtin, name, package[:unit], measure,
1775
1815
  pexf || "",
1776
1816
  ppub || "",
@@ -1780,10 +1820,10 @@ module Oddb2xml
1780
1820
  end
1781
1821
  end
1782
1822
  else # non pharma
1783
- if @@emitted_v5_gtins.index(ean13)
1823
+ if @@emitted_v6_gtins.index(ean13)
1784
1824
  next
1785
1825
  else
1786
- @@emitted_v5_gtins << ean13.clone
1826
+ @@emitted_v6_gtins << ean13.clone
1787
1827
  end
1788
1828
  # Set the pharmatype to 'Y' for outdated products, which are no longer found
1789
1829
  # in refdata/packungen
@@ -1896,7 +1936,7 @@ module Oddb2xml
1896
1936
  elexis_strftime_format = "%FT%T\.%L%:z"
1897
1937
  @@cumul_ver = (Date.today.year - 2013) * 12 + Date.today.month
1898
1938
  options_xml = {
1899
- "xmlns" => "http://elexis.ch/Elexis_Artikelstamm_v5",
1939
+ "xmlns" => "http://elexis.ch/Elexis_Artikelstamm_v6",
1900
1940
  "CREATION_DATETIME" => Time.new.strftime(elexis_strftime_format),
1901
1941
  "BUILD_DATETIME" => Time.new.strftime(elexis_strftime_format),
1902
1942
  "DATA_SOURCE" => "oddb2xml"
@@ -2008,7 +2048,7 @@ module Oddb2xml
2008
2048
  end
2009
2049
  end
2010
2050
  end
2011
- Oddb2xml.log "#{variant}. Done #{nr_products} of #{@products.size} products, #{@limitations.size} limitations and #{nr_articles}/#{@nr_articles} articles. @@emitted_v5_gtins #{@@emitted_v5_gtins.size}"
2051
+ Oddb2xml.log "#{variant}. Done #{nr_products} of #{@products.size} products, #{@limitations.size} limitations and #{nr_articles}/#{@nr_articles} articles. @@emitted_v6_gtins #{@@emitted_v6_gtins.size}"
2012
2052
  # we don't add a SHA256 hash for each element in the article
2013
2053
  # Oddb2xml.add_hash(a_builder.to_xml)
2014
2054
  # doc = REXML::Document.new( source, { :raw => :all })
@@ -2018,13 +2058,13 @@ module Oddb2xml
2018
2058
  lines << " - #{sprintf("%5d", @limitations.size)} limitations"
2019
2059
  lines << " - #{sprintf("%5d", @nr_articles)} articles"
2020
2060
  lines << " - #{sprintf("%5d", @@gtin2ignore.size)} ignored GTINS"
2021
- @@articlestamm_v5_info_lines = lines
2061
+ @@articlestamm_v6_info_lines = lines
2022
2062
  a_builder.to_xml({indent: 4, encoding: "UTF-8"})
2023
2063
  end
2024
2064
 
2025
2065
  private_class_method
2026
- def self.articlestamm_v5_info_lines
2027
- @@articlestamm_v5_info_lines
2066
+ def self.articlestamm_v6_info_lines
2067
+ @@articlestamm_v6_info_lines
2028
2068
  end
2029
2069
  end
2030
2070
  end
data/lib/oddb2xml/cli.rb CHANGED
@@ -103,8 +103,8 @@ module Oddb2xml
103
103
  end
104
104
  build
105
105
  if @options[:artikelstamm] && system("which xmllint")
106
- elexis_v5_xsd = File.expand_path(File.join(__FILE__, "..", "..", "..", "Elexis_Artikelstamm_v5.xsd"))
107
- cmd = "xmllint --noout --schema #{elexis_v5_xsd} #{@the_files[:artikelstamm]}"
106
+ elexis_v6_xsd = File.expand_path(File.join(__FILE__, "..", "..", "..", "Elexis_Artikelstamm_v6.xsd"))
107
+ cmd = "xmllint --noout --schema #{elexis_v6_xsd} #{@the_files[:artikelstamm]}"
108
108
  if system(cmd)
109
109
  puts "Validatied #{@the_files[:artikelstamm]}"
110
110
  else
@@ -399,7 +399,7 @@ module Oddb2xml
399
399
  @the_files[:calc] = "oddb_calc.xml"
400
400
  end
401
401
  if @options[:artikelstamm]
402
- @the_files[:artikelstamm] = "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v5.xml"
402
+ @the_files[:artikelstamm] = "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v6.xml"
403
403
  elsif @options[:address]
404
404
  @the_files[:company] = "#{prefix}_betrieb.xml"
405
405
  @the_files[:person] = "#{prefix}_medizinalperson.xml"
@@ -438,7 +438,7 @@ module Oddb2xml
438
438
  end
439
439
  if @options[:artikelstamm]
440
440
  lines << "Generated artikelstamm.xml for Elexis"
441
- lines += Builder.articlestamm_v5_info_lines
441
+ lines += Builder.articlestamm_v6_info_lines
442
442
  elsif @options[:address]
443
443
  {
444
444
  "Betrieb" => :@companies,
@@ -586,6 +586,10 @@ module Oddb2xml
586
586
  limitation.LimitationNiveau = "" # Not in FHIR
587
587
  limitation.LimitationValue = "" # Not in FHIR
588
588
  limitation.LimitationCudRef = cud_ref # carried through for FR/IT resolution
589
+ # BAG Indikationscode (XXXXX.NN): the v6 Artikelstamm <ARTSL>/<ARTLIM>
590
+ # block carries it per article (issue #113). Independent of the CUD id
591
+ # (= LimitationCode), so read the explicit indicationCode extension.
592
+ limitation.IndicationCode = lim[:indication_code] || ""
589
593
  limitation.DescriptionDe = text_de
590
594
  limitation.DescriptionFr = "" # filled by merge_language from FR bundle
591
595
  limitation.DescriptionIt = "" # filled by merge_language from IT bundle
@@ -819,7 +823,9 @@ module Oddb2xml
819
823
  desc_fr: lim.DescriptionFr || "",
820
824
  desc_it: lim.DescriptionIt || "",
821
825
  cud_ref: lim.LimitationCudRef,
826
+ indcd: lim.IndicationCode || "",
822
827
  vdate: lim.ValidFromDate || "",
828
+ vtdate: lim.ValidThruDate || "",
823
829
  del: is_deleted
824
830
  }
825
831
  end
@@ -17,7 +17,7 @@ module Oddb2xml
17
17
  produced files are found under data
18
18
  EOS
19
19
  opt :append, "Additional target nonpharma", default: false
20
- opt :artikelstamm, "Create Artikelstamm Version 3 and 5 for Elexis >= 3.1"
20
+ opt :artikelstamm, "Create Artikelstamm Version 6 for Elexis >= 3.1"
21
21
  opt :compress_ext, "format F. {tar.gz|zip}", type: :string, default: nil, short: "c"
22
22
  opt :extended, "pharma, non-pharma plus prices and non-pharma from zurrose.
23
23
  Products without EAN-Code will also be listed.
@@ -1,3 +1,3 @@
1
1
  module Oddb2xml
2
- VERSION = "3.0.25"
2
+ VERSION = "3.0.26"
3
3
  end
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Generate the mediupdatexml.oddb.org landing page ($DOCROOT/index.html) with
4
+ # live product counts:
5
+ # PHARMA = Swissmedic-registered medicines (<SMNO> in default/oddb_article.xml)
6
+ # NONPHARMA = GTINs in the GS1 firstbase download (firstbase.csv, minus header)
7
+ #
8
+ # Usage: generate_index_html.sh DOCROOT [FIRSTBASE_CSV]
9
+ # DOCROOT where to write index.html (default /home/zdavatz/oddb2xml)
10
+ # FIRSTBASE_CSV the GS1 firstbase CSV (default <DOCROOT>-build/downloads/firstbase.csv)
11
+ #
12
+ # Called from run_oddb2xml.sh (after each build) and from
13
+ # setup_mediupdatexml_web.sh (initial creation). Counts that can't be computed
14
+ # are shown as "—".
15
+
16
+ set -euo pipefail
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ DOCROOT="${1:-/home/zdavatz/oddb2xml}"
20
+ FIRSTBASE_CSV="${2:-${DOCROOT%/}-build/downloads/firstbase.csv}"
21
+ ARTICLE_XML="${DOCROOT%/}/default/oddb_article.xml"
22
+
23
+ # Apache combined access log(s) for the visitors/region graph (last STATS_DAYS).
24
+ # Readable by the user only if it is in the "adm" group (sudo usermod -aG adm <user>).
25
+ ACCESS_LOG_GLOB="${ACCESS_LOG_GLOB:-/var/log/apache2/mediupdatexml.oddb.org_access.log*}"
26
+ STATS_DAYS="${STATS_DAYS:-14}"
27
+
28
+ # Swiss-style thousands separator (192807 -> 192'807); "—" passes through.
29
+ group() { [[ "$1" =~ ^[0-9]+$ ]] && printf "%s" "$1" | sed -re ":a;s/([0-9])([0-9]{3})($|[^0-9])/\1'\2\3/;ta" || printf "%s" "$1"; }
30
+
31
+ total="—"
32
+ [[ -f "$ARTICLE_XML" ]] && total=$(grep -c '<ART ' "$ARTICLE_XML" || true)
33
+
34
+ pharma="—"
35
+ [[ -f "$ARTICLE_XML" ]] && pharma=$(grep -c '<SMNO>' "$ARTICLE_XML" || true)
36
+
37
+ nonpharma="—"
38
+ [[ -f "$FIRSTBASE_CSV" ]] && nonpharma=$(( $(wc -l < "$FIRSTBASE_CSV") - 1 ))
39
+
40
+ stand=$(date '+%d.%m.%Y %H:%M')
41
+
42
+ # Visitors/sessions/region graph as a ready-to-embed inline-SVG HTML fragment.
43
+ # Self-contained (pure Python stdlib + cached DB-IP country CSV). Stays empty
44
+ # when the access log is unreadable or has no data, so the page degrades to
45
+ # simply omitting the section.
46
+ stats_fragment=""
47
+ if [[ -f "$SCRIPT_DIR/visitor_stats.py" ]]; then
48
+ stats_fragment=$(python3 "$SCRIPT_DIR/visitor_stats.py" \
49
+ "$ACCESS_LOG_GLOB" "$(dirname "$FIRSTBASE_CSV")" "$STATS_DAYS" 2>/dev/null || true)
50
+ fi
51
+
52
+ mkdir -p "$DOCROOT"
53
+
54
+ # Logo (self-contained SVG): brand-blue rounded badge, white pharma/Swiss cross
55
+ # flanked by XML angle brackets "< >". Written atomically next to index.html and
56
+ # used both as the top-right header image and as the favicon.
57
+ logo_tmp="${DOCROOT%/}/.logo.svg.$$"
58
+ cat > "$logo_tmp" <<'SVG'
59
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="oddb2xml">
60
+ <defs>
61
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
62
+ <stop offset="0" stop-color="#1a6dff"/>
63
+ <stop offset="1" stop-color="#0a3d8f"/>
64
+ </linearGradient>
65
+ </defs>
66
+ <rect width="64" height="64" rx="14" fill="url(#g)"/>
67
+ <g stroke="#e2231a" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" fill="none">
68
+ <polyline points="15,24 9,32 15,40"/>
69
+ <polyline points="49,24 55,32 49,40"/>
70
+ </g>
71
+ <g fill="#ffffff">
72
+ <rect x="26.5" y="18" width="11" height="28" rx="2.5"/>
73
+ <rect x="18" y="26.5" width="28" height="11" rx="2.5"/>
74
+ </g>
75
+ </svg>
76
+ SVG
77
+ chmod 644 "$logo_tmp"
78
+ mv -f "$logo_tmp" "${DOCROOT%/}/logo.svg"
79
+
80
+ # Write atomically via temp + mv so the page can be refreshed regardless of who
81
+ # owns the existing index.html (setup runs as root, run_oddb2xml.sh as the user);
82
+ # mv only needs write on the directory, which both have.
83
+ tmp="${DOCROOT%/}/.index.html.$$"
84
+ cat > "$tmp" <<HTML
85
+ <!DOCTYPE html>
86
+ <html lang="de">
87
+ <head>
88
+ <meta charset="utf-8">
89
+ <meta name="viewport" content="width=device-width, initial-scale=1">
90
+ <title>mediupdatexml.oddb.org — Downloads</title>
91
+ <!-- open every link in a new tab -->
92
+ <base target="_blank">
93
+ <meta name="referrer" content="strict-origin-when-cross-origin">
94
+ <link rel="icon" type="image/svg+xml" href="logo.svg">
95
+ <style>
96
+ body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
97
+ max-width: 820px; margin: 2.5rem auto; padding: 0 1.2rem; color: #1a1a1a; line-height: 1.5; }
98
+ h1 { font-size: 1.6rem; margin-bottom: .2rem; }
99
+ h2 { font-size: 1.15rem; margin-top: 2rem; border-bottom: 1px solid #ddd; padding-bottom: .3rem; }
100
+ .sub { color: #666; margin-top: 0; }
101
+ ul { list-style: none; padding-left: 0; }
102
+ li { margin: .4rem 0; }
103
+ a { color: #0a58ca; text-decoration: none; }
104
+ a:hover { text-decoration: underline; }
105
+ .desc { color: #666; font-size: .9rem; }
106
+ code { background: #f4f4f4; padding: .1rem .3rem; border-radius: 3px; }
107
+ .stats { display: flex; gap: 1.5rem; margin: 1rem 0; flex-wrap: wrap; }
108
+ .stat { background: #f4f7fb; border: 1px solid #dce4ef; border-radius: 8px; padding: .8rem 1.2rem; }
109
+ .stat .n { font-size: 1.5rem; font-weight: 600; color: #0a58ca; }
110
+ .stat .l { color: #555; font-size: .85rem; }
111
+ footer { margin-top: 3rem; color: #888; font-size: .85rem; }
112
+ ul.firms { columns: 2; column-gap: 2rem; }
113
+ ul.firms li { margin: .25rem 0; break-inside: avoid; }
114
+ .topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
115
+ .topbar .title h1 { margin: 0 0 .2rem; }
116
+ .topbar .logo { width: 64px; height: 64px; flex: 0 0 auto; }
117
+ </style>
118
+ </head>
119
+ <body>
120
+ <header class="topbar">
121
+ <div class="title">
122
+ <h1>oddb2xml &amp; aips2sqlite Downloads</h1>
123
+ <p class="sub">Schweizer Arzneimitteldaten — täglich aktualisiert (01:00 Uhr). Stand: ${stand}</p>
124
+ </div>
125
+ <a href="mailto:zdavatz@ywesee.com" title="Fragen? zdavatz at ywesee dot com"><img class="logo" src="logo.svg" alt="oddb2xml Logo" width="64" height="64"></a>
126
+ </header>
127
+
128
+ <div class="stats">
129
+ <div class="stat"><div class="n">$(group "$pharma")</div><div class="l">Medikamente (PHARMA)</div></div>
130
+ <div class="stat"><div class="n"><a href="https://id.gs1.ch/01/07612345000961">$(group "$nonpharma")</a></div><div class="l">Firstbase-Produkte (NONPHARMA)</div></div>
131
+ <div class="stat"><div class="n"><a href="default/oddb_article.xml">$(group "$total")</a></div><div class="l">Artikel total (<a href="default/oddb_article.xml"><code>oddb_article.xml</code></a>)</div></div>
132
+ </div>
133
+
134
+ <h2>oddb2xml — Artikel-/Produkt-Feeds (<code>-b</code> firstbase)</h2>
135
+ <ul>
136
+ <li><a href="default/">default/</a> <span class="desc">— ohne Preisaufschlag</span></li>
137
+ <li><a href="45/">45/</a> <span class="desc">— Wiederverkaufspreis +45&nbsp;%</span></li>
138
+ <li><a href="50/">50/</a> <span class="desc">— Wiederverkaufspreis +50&nbsp;%</span></li>
139
+ <li><a href="55/">55/</a> <span class="desc">— Wiederverkaufspreis +55&nbsp;%</span></li>
140
+ </ul>
141
+ <p class="desc">Jedes Verzeichnis enthält die gleichen Dateien (Direktlinks zum <code>default/</code>-Feed):
142
+ <a href="default/oddb_article.xml">oddb_article.xml</a>,
143
+ <a href="default/oddb_product.xml">oddb_product.xml</a>,
144
+ <a href="default/oddb_calc.xml">oddb_calc.xml</a>,
145
+ <a href="default/oddb_interaction.xml">oddb_interaction.xml</a>,
146
+ <a href="default/oddb_limitation.xml">oddb_limitation.xml</a>,
147
+ <a href="default/oddb_substance.xml">oddb_substance.xml</a>,
148
+ <a href="default/oddb_code.xml">oddb_code.xml</a> sowie
149
+ <a href="default/oddb2xml.zip">oddb2xml.zip</a> (alle Dateien gepackt).</p>
150
+
151
+ <h2>aips2sqlite — Fachinformationen &amp; AmiKo-Datenbanken</h2>
152
+ <ul>
153
+ <li><a href="/aips2sqlite/fis/">fis/</a> <span class="desc">— Fachinformationen als XML/HTML (DE/FR/IT)</span></li>
154
+ <li><a href="/aips2sqlite/amiko_db_full_idx_de.db">amiko_db_full_idx_de.db</a> <span class="desc">— AmiKo-Datenbank Deutsch</span></li>
155
+ <li><a href="/aips2sqlite/amiko_db_full_idx_fr.db">amiko_db_full_idx_fr.db</a> <span class="desc">— AmiKo-Datenbank Französisch</span></li>
156
+ <li><a href="/aips2sqlite/oddb2xml_swissmedic_sequences.csv">oddb2xml_swissmedic_sequences.csv</a> <span class="desc">— Swissmedic-Sequenzen</span></li>
157
+ <li><a href="/aips2sqlite/atc_codes_used_set.txt">atc_codes_used_set.txt</a> <span class="desc">— verwendete ATC-Codes</span></li>
158
+ <li><a href="/aips2sqlite/">/aips2sqlite/</a> <span class="desc">— gesamtes Verzeichnis durchsuchen</span></li>
159
+ </ul>
160
+
161
+ <h2>MediUpdate XML bei HIN</h2>
162
+ <ul>
163
+ <li><a href="https://www.hin.ch/de/services/mediupdate-xml.cfm">www.hin.ch/de/services/mediupdate-xml.cfm</a></li>
164
+ </ul>
165
+
166
+ <h2>Softwarehäuser, die oddb2xml einsetzen</h2>
167
+ <ul class="firms">
168
+ <li><a href="https://www.advancedconcepts.ch/">Advanced Concepts AG</a></li>
169
+ <li><a href="https://www.bluecare.ch/">Bluecare AG</a></li>
170
+ <li><a href="https://corona.ch/">Corona Informatik AG</a></li>
171
+ <li><a href="https://www.derma2go.com/de/">derma2go AG</a></li>
172
+ <li><a href="https://www.diagnosia.com/">Diagnosia Internetservices GmbH</a></li>
173
+ <li><a href="https://elexis.ch/glp/index.html">Elexis</a></li>
174
+ <li><a href="https://www.emedswiss.ch/">emedSwiss SA</a></li>
175
+ <li><a href="https://gartenmann.ch/">Gartenmann Software AG</a></li>
176
+ <li><a href="https://hexabit.ch/">Hexabit GmbH</a></li>
177
+ <li><a href="https://www.hausarztmedizin.uzh.ch/de.html">Institut für Hausarztmedizin</a></li>
178
+ <li><a href="https://www.itw-informatik.ch/de/">ITW INFORMATIK AG</a></li>
179
+ <li><a href="https://www.lama-media.com/">Lama Media</a></li>
180
+ <li><a href="https://medab.org/country/ch">MEDAB</a></li>
181
+ <li><a href="https://www.pharmedsolutions.ch/">Pharmed Solutions GmbH</a></li>
182
+ <li><a href="https://praxinova.ch/">Praxinova AG</a></li>
183
+ <li><a href="https://seantis.ch/">seantis gmbh</a></li>
184
+ <li><a href="https://www.geteyesoft.ch/">Siplus SA – Eyesoft</a></li>
185
+ <li><a href="https://swiss-mr.ch/">SMR – Swiss Medical Record GmbH</a></li>
186
+ <li><a href="https://triboni.com/site/">Triboni AG</a></li>
187
+ <li><a href="https://www.vitabyte.ch/">Vitabyte AG</a></li>
188
+ <li><a href="https://zollsoft.de/">zollsoft GmbH</a></li>
189
+ </ul>
190
+
191
+ ${stats_fragment}
192
+
193
+ <footer>
194
+ Fragen: <a href="mailto:zdavatz@ywesee.com">zdavatz at ywesee dot com</a> &middot; Tel: 043 540 05 50
195
+ </footer>
196
+ </body>
197
+ </html>
198
+ HTML
199
+ chmod 644 "$tmp"
200
+ mv -f "$tmp" "${DOCROOT%/}/index.html"
201
+
202
+ echo "Wrote ${DOCROOT%/}/index.html (PHARMA=$pharma NONPHARMA=$nonpharma)"
@@ -38,9 +38,35 @@ BUILD_DIR="${BUILD_DIR:-${OUT_DIR%/}-build}"
38
38
  INCREMENTS="${INCREMENTS:-45 50 55}"
39
39
  ODDB2XML_BIN="${ODDB2XML_BIN:-oddb2xml}"
40
40
  TRANSFER_CMD="${TRANSFER_CMD:-$SCRIPT_DIR/transfer.sh}"
41
+ # Transient upstream download failures (e.g. Swissmedic resetting the
42
+ # connection, Errno::ECONNRESET) used to abort the whole nightly run under
43
+ # `set -e`. Retry the oddb2xml build a few times before giving up.
44
+ ODDB2XML_RETRIES="${ODDB2XML_RETRIES:-3}"
45
+ ODDB2XML_RETRY_DELAY="${ODDB2XML_RETRY_DELAY:-120}"
41
46
 
42
47
  log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
43
48
 
49
+ # run_with_retry <description> -- <command...>
50
+ # Retry a flaky command up to ODDB2XML_RETRIES times, sleeping
51
+ # ODDB2XML_RETRY_DELAY seconds between attempts. Running the command as the
52
+ # `until` condition keeps it exempt from `set -e`, so a failed attempt retries
53
+ # instead of killing the script; the final failure is propagated via return.
54
+ run_with_retry() {
55
+ local desc="$1"; shift
56
+ [[ "${1:-}" == "--" ]] && shift
57
+ local attempt=1 rc=0
58
+ until "$@"; do
59
+ rc=$?
60
+ if [[ $attempt -ge $ODDB2XML_RETRIES ]]; then
61
+ log "ERROR: $desc failed after $attempt attempts (last exit $rc)"
62
+ return $rc
63
+ fi
64
+ log "WARNING: $desc failed (exit $rc), attempt $attempt/$ODDB2XML_RETRIES; retrying in ${ODDB2XML_RETRY_DELAY}s"
65
+ sleep "$ODDB2XML_RETRY_DELAY"
66
+ attempt=$((attempt + 1))
67
+ done
68
+ }
69
+
44
70
  # 1. Install / update the published gem unless told otherwise.
45
71
  if [[ "${SKIP_GEM_INSTALL:-0}" != "1" ]]; then
46
72
  log "Installing oddb2xml gem"
@@ -69,7 +95,10 @@ build_one() {
69
95
 
70
96
  log "Building increment '${inc:-none}' -> $dest"
71
97
  rm -f oddb*.zip
72
- "$ODDB2XML_BIN" "${dl_opt[@]}" -b "${inc_opt[@]}" -c zip
98
+ # On a retry the first build re-downloads from scratch (dl_opt empty), which
99
+ # also clears any partial download left by the failed attempt.
100
+ run_with_retry "oddb2xml build '${inc:-none}'" -- \
101
+ "$ODDB2XML_BIN" "${dl_opt[@]}" -b "${inc_opt[@]}" -c zip
73
102
 
74
103
  shopt -s nullglob
75
104
  local zips=(oddb*.zip)
@@ -89,6 +118,14 @@ for inc in $INCREMENTS; do
89
118
  done
90
119
  build_one "" "default" # final run with no increment
91
120
 
121
+ # 2b. Refresh the download landing page with the live PHARMA/NONPHARMA counts
122
+ # (PHARMA from default/oddb_article.xml, NONPHARMA from the GS1 firstbase CSV).
123
+ if [[ -x "$SCRIPT_DIR/generate_index_html.sh" ]]; then
124
+ log "Refreshing landing page index.html"
125
+ "$SCRIPT_DIR/generate_index_html.sh" "$OUT_DIR" "$BUILD_DIR/downloads/firstbase.csv" || \
126
+ log "WARNING: could not regenerate index.html"
127
+ fi
128
+
92
129
  # 3. Optional hand-off to the transfer step (scripts/transfer.sh).
93
130
  if [[ "${RUN_TRANSFER:-0}" == "1" ]]; then
94
131
  log "Running transfer: $TRANSFER_CMD"
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Provision Apache to serve the oddb2xml output at https://mediupdatexml.oddb.org
4
+ # (Let's Encrypt HTTPS + browsable downloads, like pillbox.oddb.org).
5
+ #
6
+ # Serves two areas on one domain:
7
+ # / -> $DOCROOT (oddb2xml feeds: default/ 45/ 50/ 55/)
8
+ # /aips2sqlite/ -> $AIPS_OUT (FI XMLs, AmiKo .db, sequences CSV)
9
+ # plus a curated landing page ($DOCROOT/index.html) linking both.
10
+ #
11
+ # Run with: sudo scripts/setup_mediupdatexml_web.sh
12
+ #
13
+ # Idempotent: safe to re-run. HTTPS is issued/renewed automatically once the
14
+ # domain's DNS points at this host.
15
+
16
+ set -euo pipefail
17
+
18
+ DOMAIN="mediupdatexml.oddb.org"
19
+ DOCROOT="/home/zdavatz/oddb2xml"
20
+ AIPS_OUT="/home/zdavatz/software/aips2sqlite/jars/output"
21
+ EMAIL="zdavatz@gmail.com"
22
+ SITE="/etc/apache2/sites-available/${DOMAIN}.conf"
23
+
24
+ if [[ $EUID -ne 0 ]]; then
25
+ echo "Please run as root: sudo $0" >&2
26
+ exit 1
27
+ fi
28
+
29
+ echo "==> Installing apache2 + certbot"
30
+ export DEBIAN_FRONTEND=noninteractive
31
+ apt-get update -qq
32
+ apt-get install -y apache2 certbot python3-certbot-apache
33
+
34
+ echo "==> Enabling required Apache modules"
35
+ a2enmod autoindex headers >/dev/null
36
+
37
+ echo "==> Allowing www-data to traverse /home/zdavatz (711 = no listing, path access only)"
38
+ chmod 711 /home/zdavatz
39
+
40
+ echo "==> Writing vhost $SITE"
41
+ cat > "$SITE" <<EOF
42
+ <VirtualHost *:80>
43
+ ServerName ${DOMAIN}
44
+ DocumentRoot ${DOCROOT}
45
+
46
+ <Directory ${DOCROOT}>
47
+ Options +Indexes +FollowSymLinks
48
+ IndexOptions FancyIndexing HTMLTable NameWidth=* SuppressDescription FoldersFirst
49
+ Require all granted
50
+ AllowOverride None
51
+ </Directory>
52
+
53
+ ErrorLog \${APACHE_LOG_DIR}/${DOMAIN}_error.log
54
+ CustomLog \${APACHE_LOG_DIR}/${DOMAIN}_access.log combined
55
+ </VirtualHost>
56
+ EOF
57
+
58
+ echo "==> Writing aips2sqlite download alias (/aips2sqlite -> jars/output)"
59
+ cat > /etc/apache2/conf-available/mediupdatexml-aips.conf <<EOF
60
+ # aips2sqlite output (FI XMLs, AmiKo .db, swissmedic sequences CSV)
61
+ # Served on both the HTTP and HTTPS vhosts of ${DOMAIN}.
62
+ Alias /aips2sqlite ${AIPS_OUT}
63
+ <Directory ${AIPS_OUT}>
64
+ Options +Indexes +FollowSymLinks
65
+ IndexOptions FancyIndexing HTMLTable NameWidth=* SuppressDescription FoldersFirst
66
+ Require all granted
67
+ AllowOverride None
68
+ </Directory>
69
+ EOF
70
+ a2enconf mediupdatexml-aips >/dev/null
71
+
72
+ # Curated landing page (with live PHARMA/NONPHARMA counts). Lives in the OUT_DIR
73
+ # root, which run_oddb2xml.sh never touches (it only rebuilds the per-increment
74
+ # subdirs), so it survives rebuilds — and run_oddb2xml.sh refreshes the counts
75
+ # after every build via the same generator.
76
+ echo "==> Writing landing page ${DOCROOT}/index.html"
77
+ "$(dirname "$0")/generate_index_html.sh" "${DOCROOT}"
78
+
79
+ echo "==> Enabling site, disabling default"
80
+ a2ensite "${DOMAIN}.conf" >/dev/null
81
+ a2dissite 000-default.conf >/dev/null 2>&1 || true
82
+
83
+ echo "==> Testing config and reloading Apache"
84
+ apache2ctl configtest
85
+ systemctl reload apache2
86
+ systemctl enable apache2 >/dev/null 2>&1 || true
87
+
88
+ echo
89
+ echo "==> HTTP is live: http://${DOMAIN}/ (serving ${DOCROOT})"
90
+ echo
91
+
92
+ # --- HTTPS via Let's Encrypt -------------------------------------------------
93
+ # Check a public resolver (Cloudflare DoH), not the local one, which may still
94
+ # hold a negative cache. Let's Encrypt validates from its own resolvers anyway.
95
+ resolved=$(curl -s --max-time 8 \
96
+ "https://1.1.1.1/dns-query?name=${DOMAIN}&type=A" \
97
+ -H 'accept: application/dns-json' \
98
+ | python3 -c "import sys,json; a=json.load(sys.stdin).get('Answer',[]); print(a[0]['data'] if a else '')" 2>/dev/null || true)
99
+ if [[ -z "$resolved" ]]; then
100
+ echo "!! ${DOMAIN} does not resolve in public DNS yet."
101
+ echo " Once the A/AAAA records propagate, run:"
102
+ echo " sudo certbot --apache -d ${DOMAIN} --redirect -m ${EMAIL} --agree-tos -n"
103
+ exit 0
104
+ fi
105
+
106
+ echo "==> ${DOMAIN} resolves to ${resolved} — requesting Let's Encrypt cert"
107
+ certbot --apache -d "${DOMAIN}" --redirect -m "${EMAIL}" --agree-tos -n
108
+ echo
109
+ echo "==> Done. HTTPS is live: https://${DOMAIN}/"
110
+ echo " Certbot installed a systemd timer for auto-renewal."