oddb2xml 3.0.24 → 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 +4 -4
- data/.gitignore +3 -1
- data/CLAUDE.md +15 -2
- data/Elexis_Artikelstamm_v6.xsd +559 -0
- data/Gemfile.lock +1 -1
- data/History.txt +6 -0
- data/README.md +58 -1
- data/lib/oddb2xml/builder.rb +51 -11
- data/lib/oddb2xml/cli.rb +4 -4
- data/lib/oddb2xml/extractor.rb +4 -1
- data/lib/oddb2xml/fhir_support.rb +6 -0
- data/lib/oddb2xml/options.rb +1 -1
- data/lib/oddb2xml/version.rb +1 -1
- data/scripts/generate_index_html.sh +202 -0
- data/scripts/run_oddb2xml.sh +38 -1
- data/scripts/setup_mediupdatexml_web.sh +110 -0
- data/scripts/swissmedic_watch.sh +85 -0
- data/scripts/visitor_stats.py +324 -0
- data/spec/artikelstamm_spec.rb +14 -14
- data/spec/fhir_spec.rb +47 -0
- metadata +6 -1
data/History.txt
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
4
|
+
=== 3.0.25 / 17.06.2026
|
|
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).
|
|
6
|
+
|
|
1
7
|
=== 3.0.24 / 16.06.2026
|
|
2
8
|
* Bugfix (--skip-download): cached files fetched with a write mode were silently emptied on every --skip-download run. DownloadMethod#download_as restored the file from the ./downloads cache and then re-opened it with the caller's mode (e.g. "w+"), which truncated it to zero bytes before the read — so the returned data was empty. This blanked epha_interactions.csv (oddb_interaction.xml came out with NBR_RECORD=0) and any other source pulled with a "w+"-style mode (LPPV, Weleda/WALA SL, BAG SL group prices) whenever its file was present in the cache. It surfaced in deploy scripts that download once and rebuild several price increments from the shared cache (e.g. -b -I 45/50/55): the first build was correct, every --skip-download rebuild lost the interactions. The skip branch now opens the restored file read-only, preserving any encoding suffix ("w+:iso-8859-1:utf-8" -> "r:iso-8859-1:utf-8").
|
|
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
|
|
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.
|
data/lib/oddb2xml/builder.rb
CHANGED
|
@@ -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
|
-
@@
|
|
1567
|
-
@csv_file = CSV.open(File.join(WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}
|
|
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 @@
|
|
1690
|
+
if @@emitted_v6_gtins.index(pkg_gtin)
|
|
1653
1691
|
next
|
|
1654
1692
|
else
|
|
1655
|
-
@@
|
|
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 @@
|
|
1823
|
+
if @@emitted_v6_gtins.index(ean13)
|
|
1784
1824
|
next
|
|
1785
1825
|
else
|
|
1786
|
-
@@
|
|
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/
|
|
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. @@
|
|
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
|
-
@@
|
|
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.
|
|
2027
|
-
@@
|
|
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
|
-
|
|
107
|
-
cmd = "xmllint --noout --schema #{
|
|
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")}
|
|
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.
|
|
441
|
+
lines += Builder.articlestamm_v6_info_lines
|
|
442
442
|
elsif @options[:address]
|
|
443
443
|
{
|
|
444
444
|
"Betrieb" => :@companies,
|
data/lib/oddb2xml/extractor.rb
CHANGED
|
@@ -622,7 +622,10 @@ module Oddb2xml
|
|
|
622
622
|
def to_hash
|
|
623
623
|
data = {}
|
|
624
624
|
return data unless @file && File.exist?(@file)
|
|
625
|
-
CSV
|
|
625
|
+
# The GS1 firstbase CSV is served with a UTF-8 BOM. Without "bom|" the BOM
|
|
626
|
+
# glues onto the first header ("Gtin"), so row["Gtin"] is nil and every
|
|
627
|
+
# row is skipped — dropping all -b/firstbase NONPHARMA articles.
|
|
628
|
+
CSV.foreach(@file, headers: true, encoding: "bom|utf-8") do |row|
|
|
626
629
|
gtin = row["Gtin"].to_s.gsub(/^0+/, "")
|
|
627
630
|
next if gtin.empty?
|
|
628
631
|
data[gtin] = {
|
|
@@ -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
|
data/lib/oddb2xml/options.rb
CHANGED
|
@@ -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
|
|
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.
|
data/lib/oddb2xml/version.rb
CHANGED
|
@@ -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 & 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 %</span></li>
|
|
138
|
+
<li><a href="50/">50/</a> <span class="desc">— Wiederverkaufspreis +50 %</span></li>
|
|
139
|
+
<li><a href="55/">55/</a> <span class="desc">— Wiederverkaufspreis +55 %</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 & 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> · 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)"
|
data/scripts/run_oddb2xml.sh
CHANGED
|
@@ -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
|
-
|
|
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"
|