oddb2xml 3.0.25 → 3.0.27

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,9 @@
1
+ === 3.0.27 / 29.06.2026
2
+ * New (-e/--extended, -b/--firstbase): carry the BAG Indikationscode (INDC) per article in oddb_article.xml as well. Each SL price-model <ART> now gets one <INDICATION_CODE code="XXXXX.NN" cud_id="DRUG.NN" limcd="DRUG.NN" vdat="..." vtdat="...">limitation text</INDICATION_CODE> child per indication -- the same data as the v6 Artikelstamm <ARTSL> block (3.0.26), but in the flat <INDICATION_CODE> shape already emitted on <PRD> in oddb_product.xml, enriched with the limitation code (limcd) and validity dates (vdat/vtdat). The bundled oddb2xml.xsd now defines <INDICATION_CODE> and references it from both <ART> and <PRD> (the <PRD> reference was missing since the 3.0.7 product-feed addition). Only emitted in --fhir mode (the default), where limitations carry an indication code. See issue #113.
3
+
4
+ === 3.0.26 / 29.06.2026
5
+ * 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.
6
+
1
7
  === 3.0.25 / 17.06.2026
2
8
  * 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
9
 
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,56 @@ 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
+
374
+ Since 3.0.27 the same indication codes are also carried per article in
375
+ the `-e`/`--extended` and `-b`/`--firstbase` feeds: each SL price-model
376
+ `<ART>` in `oddb_article.xml` gets one `<INDICATION_CODE>` child per
377
+ indication — the flat shape already used on `<PRD>` in
378
+ `oddb_product.xml`, enriched with the limitation code and validity
379
+ dates:
380
+
381
+ ```xml
382
+ <ART DT="...">
383
+ ...
384
+ <INDICATION_CODE code="17079.01" cud_id="MABTHERA.01"
385
+ limcd="MABTHERA.01" vdat="2023-05-01T00:00:00"
386
+ vtdat="">In Kombination ...</INDICATION_CODE>
387
+ </ART>
388
+ ```
389
+
340
390
  ## Limitation texts in `--fhir` mode
341
391
 
342
392
  In 3.0.8 we fixed empty `<DescriptionDe/Fr/It>` on every `<Limitation>`
@@ -432,6 +482,29 @@ You can also run
432
482
  for your currently open Terminal to download and set the Certificate.
433
483
 
434
484
 
485
+ ## Troubleshooting downloads
486
+
487
+ oddb2xml runs a connectivity precheck and prints an `oddb2xml CONNECTIVITY
488
+ WARNING` listing any source host it cannot reach; set
489
+ `ODDB2XML_SKIP_PROXY_CHECK=1` to silence it.
490
+
491
+ A source host may finish the TLS handshake and then **reset the connection
492
+ before sending any HTTP response** (`Errno::ECONNRESET` / "Connection reset by
493
+ peer"; `wget` reports "Read error (Error in the pull function.) in headers").
494
+ When this happens for only one host while everything else is reachable, and a
495
+ normal browser still opens the site, it is usually an **anti-bot gateway (WAF)
496
+ that blocks by TLS fingerprint or source-IP reputation** rather than an outage
497
+ — a User-Agent change does not help. Mitigations: retry later from the same
498
+ host, run the download from a different egress IP, fetch the affected file with
499
+ a browser-grade client (a TLS-impersonating downloader or a headless browser)
500
+ into `downloads/` and continue with `--skip-download`, or ask the source to
501
+ allow-list the host.
502
+
503
+ The deployment driver `scripts/run_oddb2xml.sh` already retries the whole build
504
+ a few times on such transient failures (`ODDB2XML_RETRIES`,
505
+ `ODDB2XML_RETRY_DELAY`); a persistent per-host block still needs one of the
506
+ mitigations above.
507
+
435
508
  ## Testing
436
509
 
437
510
  * Calling rake spec runs spec tests.
@@ -1190,6 +1190,9 @@ module Oddb2xml
1190
1190
  xml.NINCD nincd
1191
1191
  }
1192
1192
  end
1193
+ # BAG Indikationscodes for SL price-model drugs (issue #113),
1194
+ # mirroring the v6 Artikelstamm <ARTSL> data in oddb_article.xml.
1195
+ append_indication_codes(xml, pac && pac[:limitations])
1193
1196
  end
1194
1197
  end
1195
1198
  xml.RESULT {
@@ -1562,9 +1565,66 @@ module Oddb2xml
1562
1565
  end
1563
1566
  end
1564
1567
 
1568
+ # Normalise a BAG validity date to an xs:dateTime for <VDAT>/<VTDAT>.
1569
+ # A bare date (YYYY-MM-DD) gets midnight appended (unzoned, still a valid
1570
+ # xs:dateTime); a full timestamp is passed through; blank -> nil.
1571
+ def elexis_datetime(str)
1572
+ s = str.to_s.strip
1573
+ return nil if s.empty?
1574
+ s = "#{s}T00:00:00" if /\A\d{4}-\d{2}-\d{2}\z/.match?(s)
1575
+ s
1576
+ end
1577
+
1578
+ # Emit the v6 <ARTSL> block for one ITEM: one <ARTLIM> per limitation that
1579
+ # carries a BAG Indikationscode (INDCD). Mandatory on prescriptions/invoices
1580
+ # for SL price-model drugs from 2026-07-01 (issue #113). <PM> is "true"
1581
+ # because the BAG indication code is required only for price-model SL drugs,
1582
+ # which is exactly the set of items that reach this block.
1583
+ def append_artsl(xml, limitations)
1584
+ artlims = (limitations || []).select { |lim| lim[:indcd] && !lim[:indcd].to_s.empty? }
1585
+ artlims = artlims.uniq { |lim| [lim[:code], lim[:indcd]] }
1586
+ return if artlims.empty?
1587
+ xml.ARTSL do
1588
+ xml.PM "true"
1589
+ xml.ARTLIMS do
1590
+ artlims.each do |lim|
1591
+ xml.ARTLIM do
1592
+ xml.LIMCD lim[:code].to_s
1593
+ xml.INDCD lim[:indcd].to_s
1594
+ if (vdat = elexis_datetime(lim[:vdate]))
1595
+ xml.VDAT vdat
1596
+ end
1597
+ if (vtdat = elexis_datetime(lim[:vtdate]))
1598
+ xml.VTDAT vtdat
1599
+ end
1600
+ end
1601
+ end
1602
+ end
1603
+ end
1604
+ end
1605
+
1606
+ # Emit per-article BAG Indikationscodes for the -e/--extended and
1607
+ # -b/--firstbase article feed (oddb_article.xml), one <INDICATION_CODE> per
1608
+ # limitation that carries an INDCD. Same data as the v6 <ARTSL> block, but
1609
+ # in the flat <INDICATION_CODE> shape already used on <PRD> in
1610
+ # oddb_product.xml — enriched here with the limitation code (LIMCD) and the
1611
+ # validity dates (VDAT/VTDAT). See issue #113.
1612
+ def append_indication_codes(xml, limitations)
1613
+ ics = (limitations || []).select { |lim| lim[:indcd] && !lim[:indcd].to_s.empty? }
1614
+ ics = ics.uniq { |lim| [lim[:code], lim[:indcd]] }
1615
+ ics.each do |lim|
1616
+ xml.INDICATION_CODE lim[:desc_de].to_s,
1617
+ code: lim[:indcd].to_s,
1618
+ cud_id: lim[:cud_ref].to_s,
1619
+ limcd: lim[:code].to_s,
1620
+ vdat: elexis_datetime(lim[:vdate]) || "",
1621
+ vtdat: elexis_datetime(lim[:vtdate]) || ""
1622
+ end
1623
+ end
1624
+
1565
1625
  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+")
1626
+ @@emitted_v6_gtins = []
1627
+ @csv_file = CSV.open(File.join(WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v6.csv"), "w+")
1568
1628
  @csv_file << ["gtin", "name", "pkg_size", "galenic_form", "price_ex_factory", "price_public", "prodno", "atc_code", "active_substance", "original", "it-code", "sl-liste"]
1569
1629
  @csv_file.sync = true
1570
1630
  variant = "build_artikelstamm"
@@ -1649,10 +1709,10 @@ module Oddb2xml
1649
1709
  end
1650
1710
  end
1651
1711
  info = @calc_items[pkg_gtin]
1652
- if @@emitted_v5_gtins.index(pkg_gtin)
1712
+ if @@emitted_v6_gtins.index(pkg_gtin)
1653
1713
  next
1654
1714
  else
1655
- @@emitted_v5_gtins << pkg_gtin.clone
1715
+ @@emitted_v6_gtins << pkg_gtin.clone
1656
1716
  end
1657
1717
  options = {"PHARMATYPE" => "P"}
1658
1718
  xml.ITEM(options) do
@@ -1771,6 +1831,8 @@ module Oddb2xml
1771
1831
  end
1772
1832
  end
1773
1833
  xml.PRODNO prodno if prodno
1834
+ # v6 <ARTSL>: per-article BAG Indikationscodes (issue #113).
1835
+ append_artsl(xml, package[:limitations])
1774
1836
  @csv_file << [pkg_gtin, name, package[:unit], measure,
1775
1837
  pexf || "",
1776
1838
  ppub || "",
@@ -1780,10 +1842,10 @@ module Oddb2xml
1780
1842
  end
1781
1843
  end
1782
1844
  else # non pharma
1783
- if @@emitted_v5_gtins.index(ean13)
1845
+ if @@emitted_v6_gtins.index(ean13)
1784
1846
  next
1785
1847
  else
1786
- @@emitted_v5_gtins << ean13.clone
1848
+ @@emitted_v6_gtins << ean13.clone
1787
1849
  end
1788
1850
  # Set the pharmatype to 'Y' for outdated products, which are no longer found
1789
1851
  # in refdata/packungen
@@ -1896,7 +1958,7 @@ module Oddb2xml
1896
1958
  elexis_strftime_format = "%FT%T\.%L%:z"
1897
1959
  @@cumul_ver = (Date.today.year - 2013) * 12 + Date.today.month
1898
1960
  options_xml = {
1899
- "xmlns" => "http://elexis.ch/Elexis_Artikelstamm_v5",
1961
+ "xmlns" => "http://elexis.ch/Elexis_Artikelstamm_v6",
1900
1962
  "CREATION_DATETIME" => Time.new.strftime(elexis_strftime_format),
1901
1963
  "BUILD_DATETIME" => Time.new.strftime(elexis_strftime_format),
1902
1964
  "DATA_SOURCE" => "oddb2xml"
@@ -2008,7 +2070,7 @@ module Oddb2xml
2008
2070
  end
2009
2071
  end
2010
2072
  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}"
2073
+ 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
2074
  # we don't add a SHA256 hash for each element in the article
2013
2075
  # Oddb2xml.add_hash(a_builder.to_xml)
2014
2076
  # doc = REXML::Document.new( source, { :raw => :all })
@@ -2018,13 +2080,13 @@ module Oddb2xml
2018
2080
  lines << " - #{sprintf("%5d", @limitations.size)} limitations"
2019
2081
  lines << " - #{sprintf("%5d", @nr_articles)} articles"
2020
2082
  lines << " - #{sprintf("%5d", @@gtin2ignore.size)} ignored GTINS"
2021
- @@articlestamm_v5_info_lines = lines
2083
+ @@articlestamm_v6_info_lines = lines
2022
2084
  a_builder.to_xml({indent: 4, encoding: "UTF-8"})
2023
2085
  end
2024
2086
 
2025
2087
  private_class_method
2026
- def self.articlestamm_v5_info_lines
2027
- @@articlestamm_v5_info_lines
2088
+ def self.articlestamm_v6_info_lines
2089
+ @@articlestamm_v6_info_lines
2028
2090
  end
2029
2091
  end
2030
2092
  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.27"
3
3
  end
data/oddb2xml.xsd CHANGED
@@ -169,11 +169,25 @@
169
169
  <xs:element ref="ns1:ARTBAR"/>
170
170
  <xs:element minOccurs="0" maxOccurs="unbounded" ref="ns1:ARTPRI"/>
171
171
  <xs:element minOccurs="0" ref="ns1:ARTINS"/>
172
+ <xs:element minOccurs="0" maxOccurs="unbounded" ref="ns1:INDICATION_CODE"/>
172
173
  </xs:sequence>
173
174
  <xs:attribute name="DT" use="required"/>
174
175
  <xs:attribute type="xs:string" name="SHA256" use="required"/>
175
176
  </xs:complexType>
176
177
  </xs:element>
178
+ <xs:element name="INDICATION_CODE">
179
+ <xs:complexType>
180
+ <xs:simpleContent>
181
+ <xs:extension base="xs:string">
182
+ <xs:attribute name="code" type="xs:string"/>
183
+ <xs:attribute name="cud_id" type="xs:string"/>
184
+ <xs:attribute name="limcd" type="xs:string"/>
185
+ <xs:attribute name="vdat" type="xs:string"/>
186
+ <xs:attribute name="vtdat" type="xs:string"/>
187
+ </xs:extension>
188
+ </xs:simpleContent>
189
+ </xs:complexType>
190
+ </xs:element>
177
191
  <xs:element name="PHAR" type="xs:integer"/>
178
192
  <xs:element name="SMCAT">
179
193
  <xs:simpleType>
@@ -853,6 +867,7 @@
853
867
  <xs:element minOccurs="0" ref="ns1:IT"/>
854
868
  <xs:element minOccurs="0" ref="ns1:ORPH"/>
855
869
  <xs:element ref="ns1:CPT"/>
870
+ <xs:element minOccurs="0" maxOccurs="unbounded" ref="ns1:INDICATION_CODE"/>
856
871
  <xs:element minOccurs="0" ref="ns1:PackGrSwissmedic"/>
857
872
  <xs:element minOccurs="0" ref="ns1:EinheitSwissmedic"/>
858
873
  <xs:element minOccurs="0" ref="ns1:SubstanceSwissmedic"/>
@@ -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"