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.
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # swissmedic_watch.sh — recover the nightly build automatically after a
4
+ # Swissmedic outage/block.
5
+ #
6
+ # Background: since the Swissmedic platform migration (~2026-06-23, now on a
7
+ # Swisscom-operated gateway) www.swissmedic.ch resets this host's automated
8
+ # connections after the TLS handshake (TCP RST), so run_oddb2xml.sh aborts and
9
+ # the feeds go stale. The block may be a temporary migration artefact. Rather
10
+ # than rebuild blindly, this watcher polls Swissmedic with the *same* client
11
+ # oddb2xml uses (Ruby open-uri) and, the moment it answers again, kicks off one
12
+ # build — then emails. Meant to run every 30 min from /etc/crontab:
13
+ #
14
+ # */30 * * * * zdavatz /home/zdavatz/software/oddb2xml/scripts/swissmedic_watch.sh
15
+ #
16
+ # It is a no-op while Swissmedic is still blocked, while a build is already
17
+ # running, or once today's feeds are fresh — and it fires at most once per day.
18
+
19
+ set -uo pipefail
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
+ OUT_DIR="${OUT_DIR:-/home/zdavatz/oddb2xml}"
23
+ BUILD_DIR="${BUILD_DIR:-${OUT_DIR%/}-build}"
24
+ # State (log + once-per-day stamp) lives OUTSIDE BUILD_DIR, which run_oddb2xml.sh
25
+ # deletes at the start of every build.
26
+ STATE_DIR="${STATE_DIR:-${OUT_DIR%/}-watch}"
27
+ ARTICLE_XML="$OUT_DIR/default/oddb_article.xml"
28
+ CANARY_URL="https://www.swissmedic.ch/swissmedic/de/home/services/listen_neu.html"
29
+
30
+ # Match the nightly cron's rbenv environment (the repo's .ruby-version points at
31
+ # a Ruby that isn't installed here; cron pins RBENV_VERSION instead).
32
+ export RBENV_VERSION="${RBENV_VERSION:-3.4.5}"
33
+ export PATH="/home/zdavatz/.rbenv/shims:/usr/bin:/bin"
34
+
35
+ mkdir -p "$STATE_DIR"
36
+ LOG="$STATE_DIR/swissmedic_watch.log"
37
+ today="$(date +%F)"
38
+ stamp="$STATE_DIR/.built.$today"
39
+
40
+ log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$LOG"; }
41
+
42
+ notify() { # best-effort local mail; silently skipped if no MTA
43
+ local subj="$1" body="${2:-$1}" sm=""
44
+ command -v sendmail >/dev/null 2>&1 && sm="$(command -v sendmail)"
45
+ [[ -z "$sm" && -x /usr/sbin/sendmail ]] && sm=/usr/sbin/sendmail
46
+ [[ -n "$sm" ]] && printf 'To: zdavatz\nSubject: %s\n\n%s\n' "$subj" "$body" | "$sm" -t 2>/dev/null || true
47
+ }
48
+
49
+ # Already attempted a watcher-triggered build today? (set when we launch, so a
50
+ # failed build is not relaunched every 30 min — the nightly cron / a human can.)
51
+ [[ -e "$stamp" ]] && exit 0
52
+
53
+ # A build (nightly or earlier watcher) already running? Leave it alone.
54
+ if pgrep -f 'run_oddb2xml\.sh' >/dev/null 2>&1; then
55
+ exit 0
56
+ fi
57
+
58
+ # Today's feeds already fresh (nightly cron succeeded)? Nothing to do.
59
+ if [[ -f "$ARTICLE_XML" && "$(date -r "$ARTICLE_XML" +%F)" == "$today" ]]; then
60
+ exit 0
61
+ fi
62
+
63
+ # Canary: can THIS host reach Swissmedic with oddb2xml's own client (open-uri)?
64
+ # Exit 0 only on HTTP 200; any reset/timeout/non-200 means still blocked.
65
+ if ! ruby -ropen-uri -e '
66
+ begin
67
+ URI.open(ARGV[0], open_timeout: 20, read_timeout: 25) { |f| exit(f.status[0].to_i == 200 ? 0 : 3) }
68
+ rescue => e
69
+ warn "#{e.class}: #{e.message}"; exit 1
70
+ end' "$CANARY_URL" >>"$LOG" 2>&1
71
+ then
72
+ # Still blocked — stay quiet (don't spam the log every 30 min; one line/day).
73
+ [[ -e "$STATE_DIR/.blocked.$today" ]] || { log "Swissmedic still unreachable; waiting."; : >"$STATE_DIR/.blocked.$today"; }
74
+ exit 0
75
+ fi
76
+
77
+ # Reachable again and feeds are stale -> launch exactly one build.
78
+ : >"$stamp"
79
+ log "Swissmedic reachable again — launching run_oddb2xml.sh"
80
+ notify "oddb2xml: Swissmedic reachable again — build started" \
81
+ "Swissmedic answered the open-uri canary at $(date). Starting run_oddb2xml.sh; output in $STATE_DIR/run_oddb2xml.watch.log."
82
+
83
+ nohup "$SCRIPT_DIR/run_oddb2xml.sh" >>"$STATE_DIR/run_oddb2xml.watch.log" 2>&1 &
84
+ log "launched run_oddb2xml.sh (pid $!)"
85
+ exit 0
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env python3
2
+ # visitor_stats.py — build a small, self-contained "visitors & sessions" graph
3
+ # (inline SVG HTML fragment) for the mediupdatexml.oddb.org landing page from
4
+ # the Apache combined access log, broken down by day and by region (country).
5
+ #
6
+ # Besucher (visitors) = distinct client IPs per day
7
+ # Sitzungen (sessions) = 30-min-inactivity sessions per (IP, User-Agent) per day
8
+ # Regionen (regions) = top countries of the distinct IPs in the window
9
+ #
10
+ # Region resolution is fully self-contained: it uses the free DB-IP country-lite
11
+ # CSV (CC-BY 4.0, no licence key) cached next to the other runtime downloads and
12
+ # refreshed monthly — NO apt package, NO gem, NO system GeoIP database.
13
+ #
14
+ # Usage: visitor_stats.py LOG_GLOB CACHE_DIR [DAYS]
15
+ # LOG_GLOB e.g. "/var/log/apache2/mediupdatexml.oddb.org_access.log*"
16
+ # CACHE_DIR dir to cache the DB-IP CSV (the build downloads/ dir)
17
+ # DAYS window length, default 14
18
+ #
19
+ # Prints an HTML fragment to stdout on success. Prints NOTHING and exits 0 when
20
+ # the logs cannot be read or contain no usable data, so the caller can embed the
21
+ # output unconditionally and the page degrades gracefully.
22
+
23
+ import sys, os, re, gzip, glob, csv, bisect, html, datetime, urllib.request, ipaddress
24
+
25
+ DAYS = 14
26
+ SESSION_GAP = datetime.timedelta(minutes=30)
27
+
28
+ # Requests we don't count as human "visitors". Conservative: obvious crawlers,
29
+ # monitors and scripted clients. Empty UA ("-") is also dropped.
30
+ BOT_RE = re.compile(
31
+ r"bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|"
32
+ r"monitor|uptime|pingdom|statuscake|nagios|zabbix|"
33
+ r"curl|wget|python-requests|go-http|libwww|httpclient|okhttp|"
34
+ r"scan|nmap|masscan|semrush|ahrefs|mj12|dotbot|petalbot|dataprovider",
35
+ re.I,
36
+ )
37
+
38
+ # Combined log: IP - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.0" 200 2326 "ref" "UA"
39
+ LINE_RE = re.compile(
40
+ r'^(?P<ip>\S+)\s+\S+\s+\S+\s+\[(?P<ts>[^\]]+)\]\s+'
41
+ r'"[^"]*"\s+\d{3}\s+\S+\s+"[^"]*"\s+"(?P<ua>[^"]*)"'
42
+ )
43
+ TS_FMT = "%d/%b/%Y:%H:%M:%S %z"
44
+
45
+ # Minimal CH-relevant country code -> (German name, flag emoji). Anything not
46
+ # listed falls back to the bare code; ZZ/unknown -> "Unbekannt".
47
+ CC_NAMES = {
48
+ "CH": "Schweiz", "DE": "Deutschland", "AT": "Österreich", "FR": "Frankreich",
49
+ "IT": "Italien", "LI": "Liechtenstein", "US": "USA", "GB": "Grossbritannien",
50
+ "NL": "Niederlande", "BE": "Belgien", "ES": "Spanien", "PT": "Portugal",
51
+ "PL": "Polen", "CZ": "Tschechien", "SE": "Schweden", "DK": "Dänemark",
52
+ "NO": "Norwegen", "FI": "Finnland", "IE": "Irland", "RU": "Russland",
53
+ "CN": "China", "IN": "Indien", "JP": "Japan", "BR": "Brasilien",
54
+ "CA": "Kanada", "AU": "Australien", "UA": "Ukraine", "TR": "Türkei",
55
+ "RO": "Rumänien", "HU": "Ungarn", "GR": "Griechenland", "LU": "Luxemburg",
56
+ }
57
+
58
+
59
+ def flag(cc):
60
+ if not cc or len(cc) != 2 or cc == "ZZ" or not cc.isalpha():
61
+ return "🏳"
62
+ return chr(0x1F1E6 + ord(cc[0].upper()) - 65) + chr(0x1F1E6 + ord(cc[1].upper()) - 65)
63
+
64
+
65
+ def cc_label(cc):
66
+ if not cc or cc == "ZZ":
67
+ return ("🏳", "Unbekannt")
68
+ return (flag(cc), CC_NAMES.get(cc, cc))
69
+
70
+
71
+ # ---------------------------------------------------------------- DB-IP CSV ---
72
+ def ensure_dbip(cache_dir):
73
+ """Return path to a current DB-IP country-lite CSV, downloading if needed."""
74
+ month = datetime.date.today().strftime("%Y-%m")
75
+ path = os.path.join(cache_dir, f"dbip-country-lite-{month}.csv")
76
+ if os.path.exists(path) and os.path.getsize(path) > 1_000_000:
77
+ return path
78
+ url = f"https://download.db-ip.com/free/dbip-country-lite-{month}.csv.gz"
79
+ try:
80
+ os.makedirs(cache_dir, exist_ok=True)
81
+ req = urllib.request.Request(url, headers={"User-Agent": "oddb2xml-stats/1.0"})
82
+ with urllib.request.urlopen(req, timeout=60) as r:
83
+ data = gzip.decompress(r.read())
84
+ tmp = path + ".tmp"
85
+ with open(tmp, "wb") as f:
86
+ f.write(data)
87
+ os.replace(tmp, path)
88
+ # prune older months so the cache doesn't grow unbounded
89
+ for old in glob.glob(os.path.join(cache_dir, "dbip-country-lite-*.csv")):
90
+ if old != path:
91
+ try:
92
+ os.remove(old)
93
+ except OSError:
94
+ pass
95
+ return path
96
+ except Exception:
97
+ # fall back to any cached copy we already have
98
+ existing = sorted(glob.glob(os.path.join(cache_dir, "dbip-country-lite-*.csv")))
99
+ return existing[-1] if existing else None
100
+
101
+
102
+ def load_geo(csv_path):
103
+ """Load DB-IP CSV into sorted (start_int -> cc) tables for v4 and v6."""
104
+ v4s, v4e, v4c, v6s, v6e, v6c = [], [], [], [], [], []
105
+ with open(csv_path, newline="") as f:
106
+ for row in csv.reader(f):
107
+ if len(row) < 3:
108
+ continue
109
+ start, end, cc = row[0], row[1], row[2]
110
+ try:
111
+ if ":" in start:
112
+ v6s.append(int(ipaddress.IPv6Address(start)))
113
+ v6e.append(int(ipaddress.IPv6Address(end)))
114
+ v6c.append(cc)
115
+ else:
116
+ v4s.append(int(ipaddress.IPv4Address(start)))
117
+ v4e.append(int(ipaddress.IPv4Address(end)))
118
+ v4c.append(cc)
119
+ except ipaddress.AddressValueError:
120
+ continue
121
+ return (v4s, v4e, v4c, v6s, v6e, v6c)
122
+
123
+
124
+ def lookup(geo, ip_str):
125
+ v4s, v4e, v4c, v6s, v6e, v6c = geo
126
+ try:
127
+ ip = ipaddress.ip_address(ip_str)
128
+ except ValueError:
129
+ return "ZZ"
130
+ n = int(ip)
131
+ if ip.version == 4:
132
+ starts, ends, ccs = v4s, v4e, v4c
133
+ else:
134
+ starts, ends, ccs = v6s, v6e, v6c
135
+ i = bisect.bisect_right(starts, n) - 1
136
+ if 0 <= i < len(starts) and n <= ends[i]:
137
+ return ccs[i] or "ZZ"
138
+ return "ZZ"
139
+
140
+
141
+ # ------------------------------------------------------------------- parse ---
142
+ def open_log(path):
143
+ return gzip.open(path, "rt", errors="replace") if path.endswith(".gz") \
144
+ else open(path, "rt", errors="replace")
145
+
146
+
147
+ def parse_logs(log_glob, days):
148
+ cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=days)
149
+ # per day: set of IPs (visitors); per (ip,ua): sorted timestamps for sessions
150
+ day_ips = {} # "YYYY-MM-DD" -> set(ip)
151
+ last_seen = {} # (day, ip, ua) -> last datetime (for session gaps)
152
+ day_sessions = {} # day -> session count
153
+ all_ips = set()
154
+ any_line = False
155
+
156
+ files = sorted(glob.glob(log_glob))
157
+ for path in files:
158
+ try:
159
+ fh = open_log(path)
160
+ except OSError:
161
+ continue
162
+ with fh:
163
+ for line in fh:
164
+ m = LINE_RE.match(line)
165
+ if not m:
166
+ continue
167
+ ua = m.group("ua")
168
+ if not ua or ua == "-" or BOT_RE.search(ua):
169
+ continue
170
+ try:
171
+ ts = datetime.datetime.strptime(m.group("ts"), TS_FMT)
172
+ except ValueError:
173
+ continue
174
+ if ts.astimezone(datetime.timezone.utc) < cutoff:
175
+ continue
176
+ any_line = True
177
+ ip = m.group("ip")
178
+ day = ts.strftime("%Y-%m-%d")
179
+ day_ips.setdefault(day, set()).add(ip)
180
+ all_ips.add(ip)
181
+ key = (day, ip, ua)
182
+ prev = last_seen.get(key)
183
+ if prev is None or (ts - prev) > SESSION_GAP:
184
+ day_sessions[day] = day_sessions.get(day, 0) + 1
185
+ last_seen[key] = ts
186
+ if not any_line:
187
+ return None
188
+ return day_ips, day_sessions, all_ips
189
+
190
+
191
+ # ------------------------------------------------------------------ render ---
192
+ def esc(s):
193
+ return html.escape(str(s), quote=True)
194
+
195
+
196
+ def render(day_ips, day_sessions, region_counts, days):
197
+ today = datetime.date.today()
198
+ span = [(today - datetime.timedelta(days=i)) for i in range(days - 1, -1, -1)]
199
+ labels = [d.strftime("%Y-%m-%d") for d in span]
200
+ visitors = [len(day_ips.get(l, ())) for l in labels]
201
+ sessions = [day_sessions.get(l, 0) for l in labels]
202
+ peak = max(visitors + sessions + [1])
203
+
204
+ # geometry
205
+ W, H = 760, 200
206
+ pad_l, pad_r, pad_t, pad_b = 34, 12, 14, 34
207
+ plot_w = W - pad_l - pad_r
208
+ plot_h = H - pad_t - pad_b
209
+ n = len(labels)
210
+ slot = plot_w / n
211
+ bw = min(slot * 0.36, 16) # bar width per series
212
+ gap = bw * 0.15
213
+
214
+ def x_of(i):
215
+ return pad_l + slot * i + slot / 2
216
+
217
+ def y_of(v):
218
+ return pad_t + plot_h - (v / peak) * plot_h
219
+
220
+ parts = [f'<svg viewBox="0 0 {W} {H}" width="100%" role="img" '
221
+ f'aria-label="Besucher und Sitzungen pro Tag" '
222
+ f'style="max-width:{W}px;font-family:system-ui,sans-serif">']
223
+
224
+ # y gridlines + labels (0, mid, peak)
225
+ for frac in (0, 0.5, 1):
226
+ val = round(peak * frac)
227
+ y = y_of(val)
228
+ parts.append(f'<line x1="{pad_l}" y1="{y:.1f}" x2="{W-pad_r}" y2="{y:.1f}" '
229
+ f'stroke="#e6ecf5" stroke-width="1"/>')
230
+ parts.append(f'<text x="{pad_l-6}" y="{y+3:.1f}" text-anchor="end" '
231
+ f'font-size="9" fill="#9aa6b2">{val}</text>')
232
+
233
+ # bars
234
+ for i in range(n):
235
+ cx = x_of(i)
236
+ vx = cx - bw - gap / 2
237
+ sx = cx + gap / 2
238
+ vy, sy = y_of(visitors[i]), y_of(sessions[i])
239
+ parts.append(f'<rect x="{vx:.1f}" y="{vy:.1f}" width="{bw:.1f}" '
240
+ f'height="{pad_t+plot_h-vy:.1f}" rx="1.5" fill="#0a58ca">'
241
+ f'<title>{esc(labels[i])}: {visitors[i]} Besucher</title></rect>')
242
+ parts.append(f'<rect x="{sx:.1f}" y="{sy:.1f}" width="{bw:.1f}" '
243
+ f'height="{pad_t+plot_h-sy:.1f}" rx="1.5" fill="#7eb0f4">'
244
+ f'<title>{esc(labels[i])}: {sessions[i]} Sitzungen</title></rect>')
245
+ # x label: short day (only every other if crowded)
246
+ if n <= 16 or i % 2 == 0:
247
+ parts.append(f'<text x="{cx:.1f}" y="{H-pad_b+13}" text-anchor="middle" '
248
+ f'font-size="9" fill="#7a8896">{esc(span[i].strftime("%d.%m"))}</text>')
249
+
250
+ parts.append("</svg>")
251
+ chart = "".join(parts)
252
+
253
+ # legend
254
+ legend = ('<div class="vs-legend">'
255
+ '<span><i style="background:#0a58ca"></i>Besucher (eindeutige IP/Tag)</span>'
256
+ '<span><i style="background:#7eb0f4"></i>Sitzungen (30-Min-Inaktivität)</span>'
257
+ '</div>')
258
+
259
+ # region bars
260
+ total_ips = sum(c for _, c in region_counts) or 1
261
+ rows = []
262
+ for cc, cnt in region_counts:
263
+ fl, name = cc_label(cc)
264
+ pct = cnt / total_ips * 100
265
+ rows.append(
266
+ f'<div class="vs-row"><span class="vs-cc">{fl}&nbsp;{esc(name)}</span>'
267
+ f'<span class="vs-bar"><i style="width:{pct:.1f}%"></i></span>'
268
+ f'<span class="vs-num">{cnt}</span></div>')
269
+ regions = ('<div class="vs-regions"><div class="vs-rt">Regionen (nach IP)</div>'
270
+ + "".join(rows) + "</div>")
271
+
272
+ tot_v = sum(visitors)
273
+ tot_s = sum(sessions)
274
+ style = (
275
+ "<style>"
276
+ ".vs-wrap{margin:.4rem 0 0}"
277
+ ".vs-legend{display:flex;gap:1.2rem;flex-wrap:wrap;font-size:.8rem;color:#555;margin:.3rem 0 .8rem}"
278
+ ".vs-legend i{display:inline-block;width:11px;height:11px;border-radius:2px;margin-right:.35rem;vertical-align:-1px}"
279
+ ".vs-regions{margin-top:1rem;max-width:480px}"
280
+ ".vs-rt{font-size:.85rem;color:#555;margin-bottom:.4rem}"
281
+ ".vs-row{display:flex;align-items:center;gap:.6rem;margin:.18rem 0;font-size:.85rem}"
282
+ ".vs-cc{flex:0 0 150px}"
283
+ ".vs-bar{flex:1;background:#eef2f8;border-radius:4px;height:11px;overflow:hidden}"
284
+ ".vs-bar i{display:block;height:100%;background:#0a58ca}"
285
+ ".vs-num{flex:0 0 46px;text-align:right;color:#555;font-variant-numeric:tabular-nums}"
286
+ "</style>"
287
+ )
288
+ return (
289
+ f'{style}<h2>Zugriffe (letzte {days} Tage, ohne Bots)</h2>'
290
+ f'<div class="vs-wrap">{legend}{chart}'
291
+ f'<p class="desc">Summe: {tot_v} Besucher · {tot_s} Sitzungen · '
292
+ f'{len(region_counts)} Regionen.</p>{regions}</div>'
293
+ )
294
+
295
+
296
+ def main():
297
+ if len(sys.argv) < 3:
298
+ return 0
299
+ log_glob, cache_dir = sys.argv[1], sys.argv[2]
300
+ days = int(sys.argv[3]) if len(sys.argv) > 3 else DAYS
301
+
302
+ parsed = parse_logs(log_glob, days)
303
+ if not parsed:
304
+ return 0 # no readable/usable logs -> emit nothing
305
+ day_ips, day_sessions, all_ips = parsed
306
+
307
+ region_counts = []
308
+ csv_path = ensure_dbip(cache_dir)
309
+ if csv_path:
310
+ geo = load_geo(csv_path)
311
+ counts = {}
312
+ for ip in all_ips:
313
+ counts[lookup(geo, ip)] = counts.get(lookup(geo, ip), 0) + 1
314
+ region_counts = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:6]
315
+
316
+ sys.stdout.write(render(day_ips, day_sessions, region_counts, days))
317
+ return 0
318
+
319
+
320
+ if __name__ == "__main__":
321
+ try:
322
+ sys.exit(main())
323
+ except Exception:
324
+ sys.exit(0) # never break the page build
@@ -20,12 +20,12 @@ describe Oddb2xml::Builder do
20
20
  @saved_dir = Dir.pwd
21
21
  @oddb2xml_xsd = File.expand_path(File.join(File.dirname(__FILE__), "..", "oddb2xml.xsd"))
22
22
  @oddb_calc_xsd = File.expand_path(File.join(File.dirname(__FILE__), "..", "oddb_calc.xsd"))
23
- @elexis_v5_xsd = File.expand_path(File.join(__FILE__, "..", "..", "Elexis_Artikelstamm_v5.xsd"))
24
- @elexis_v5_csv = File.join(Oddb2xml::WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v5.csv")
23
+ @elexis_v6_xsd = File.expand_path(File.join(__FILE__, "..", "..", "Elexis_Artikelstamm_v6.xsd"))
24
+ @elexis_v6_csv = File.join(Oddb2xml::WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v6.csv")
25
25
 
26
26
  expect(File.exist?(@oddb2xml_xsd)).to eq true
27
27
  expect(File.exist?(@oddb_calc_xsd)).to eq true
28
- expect(File.exist?(@elexis_v5_xsd)).to eq true
28
+ expect(File.exist?(@elexis_v6_xsd)).to eq true
29
29
  cleanup_directories_before_run
30
30
  FileUtils.makedirs(Oddb2xml::WORK_DIR)
31
31
  Dir.chdir(Oddb2xml::WORK_DIR)
@@ -45,7 +45,7 @@ describe Oddb2xml::Builder do
45
45
  options = Oddb2xml::Options.parse(["--artikelstamm", "--no-fhir"]) # , '--log'])
46
46
  # @res = buildr_capture(:stdout){ Oddb2xml::Cli.new(options).run }
47
47
  Oddb2xml::Cli.new(options).run # to debug
48
- @artikelstamm_name = File.join(Oddb2xml::WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v5.xml")
48
+ @artikelstamm_name = File.join(Oddb2xml::WORK_DIR, "artikelstamm_#{Date.today.strftime("%d%m%Y")}_v6.xml")
49
49
  @doc = Nokogiri::XML(File.open(@artikelstamm_name))
50
50
  # @rexml = REXML::Document.new File.read(@artikelstamm_name)
51
51
  @inhalt = IO.read(@artikelstamm_name)
@@ -101,20 +101,20 @@ describe Oddb2xml::Builder do
101
101
  expect(@inhalt.index(expected)).not_to be nil
102
102
  end
103
103
 
104
- it "should produce a Elexis_Artikelstamm_v5.csv" do
105
- expect(File.exist?(@elexis_v5_csv)).to eq true
106
- inhalt = File.open(@elexis_v5_csv, "r+").read
104
+ it "should produce a Elexis_Artikelstamm_v6.csv" do
105
+ expect(File.exist?(@elexis_v6_csv)).to eq true
106
+ inhalt = File.open(@elexis_v6_csv, "r+").read
107
107
  expect(inhalt.size).to be > 0
108
108
  expect(inhalt).to match(/7680284860144/)
109
109
  end
110
110
 
111
111
  it "should NOT generate a v3 nonpharma xml" do
112
- v3_name = @artikelstamm_name.sub("_v5.xml", "_v3.xml").sub("artikelstamm_", "artikelstamm_N_")
112
+ v3_name = @artikelstamm_name.sub("_v6.xml", "_v3.xml").sub("artikelstamm_", "artikelstamm_N_")
113
113
  expect(File.exist?(v3_name)).to eq false
114
114
  end
115
115
 
116
116
  it "should NOT generate a vx pharma xml" do
117
- v3_name = @artikelstamm_name.sub("_v5.xml", "_v3.xml").sub("artikelstamm_", "artikelstamm_P_")
117
+ v3_name = @artikelstamm_name.sub("_v6.xml", "_v3.xml").sub("artikelstamm_", "artikelstamm_P_")
118
118
  expect(File.exist?(v3_name)).to eq false
119
119
  end
120
120
 
@@ -123,8 +123,8 @@ describe Oddb2xml::Builder do
123
123
  end
124
124
 
125
125
  it "should find price from Preparations.xml by setting" do
126
- expect(File.exist?(@elexis_v5_csv)).to eq true
127
- inhalt = File.open(@elexis_v5_csv, "r+").read
126
+ expect(File.exist?(@elexis_v6_csv)).to eq true
127
+ inhalt = File.open(@elexis_v6_csv, "r+").read
128
128
  expected = %(7680658560014,Dibase 10'000 Tropfen 10000 IE/ml Fl 10 ml,,Flasche(n),5,9.25,6585601,A11CC05,,"",,SL)
129
129
  expect(inhalt.index(expected)).to be > 0
130
130
  end
@@ -141,8 +141,8 @@ describe Oddb2xml::Builder do
141
141
  end
142
142
 
143
143
  it "should have a price for Lynparza" do
144
- expect(File.exist?(@elexis_v5_csv)).to eq true
145
- inhalt = File.open(@elexis_v5_csv, "r+").read
144
+ expect(File.exist?(@elexis_v6_csv)).to eq true
145
+ inhalt = File.open(@elexis_v6_csv, "r+").read
146
146
  expect(inhalt.index('7680651600014,Lynparza Kaps 50 mg 448 Stk,,Kapsel(n),5562.48,5947.55,6516001,L01XX46,,"",,S')).not_to be nil
147
147
  end
148
148
  it "should trim the ean13 to 13 length" do
@@ -240,7 +240,7 @@ describe Oddb2xml::Builder do
240
240
  # Should also check for price!
241
241
  end
242
242
  it "should validate against artikelstamm.xsd" do
243
- validate_via_xsd(@elexis_v5_xsd, @artikelstamm_name)
243
+ validate_via_xsd(@elexis_v6_xsd, @artikelstamm_name)
244
244
  end
245
245
  tests = {"item 7680403330459 CARBADERM only in Preparations(SL) with public price" =>
246
246
  %(<ITEM PHARMATYPE="P">
data/spec/fhir_spec.rb CHANGED
@@ -87,6 +87,19 @@ describe "FHIR Indikationscode support" do
87
87
  pkg = item[:packages].values.first
88
88
  expect(pkg[:indication_codes]).to eq(item[:indication_codes])
89
89
  end
90
+
91
+ it "carries the indication code (INDCD) and validity dates on each package limitation" do
92
+ data = described_class.new(cyramza_fixture).to_hash
93
+ pkg = data.values.first[:packages].values.first
94
+ by_code = pkg[:limitations].each_with_object({}) { |l, h| h[l[:code]] = l }
95
+
96
+ # INDCD is the BAG Indikationscode, independent of the CUD id (= LIMCD).
97
+ expect(by_code["CYRAMZA.01"][:indcd]).to eq("20403.01")
98
+ expect(by_code["CYRAMZA.02"][:indcd]).to eq("20403.02")
99
+ # Validity dates feed <VDAT>/<VTDAT> in the v6 <ARTSL> block.
100
+ expect(by_code["CYRAMZA.01"][:vdate]).to eq("2024-10-01")
101
+ expect(by_code["CYRAMZA.02"][:vtdate]).to eq("2027-09-30")
102
+ end
90
103
  end
91
104
 
92
105
  describe Oddb2xml::FhirExtractor, "limitation text resolution" do
@@ -209,4 +222,38 @@ describe "FHIR Indikationscode support" do
209
222
  expect(xml).to include("In Kombination mit FOLFIRI")
210
223
  end
211
224
  end
225
+
226
+ describe Oddb2xml::Builder, "v6 ARTSL/INDCD emission" do
227
+ def artsl_xml(limitations)
228
+ builder = described_class.new
229
+ Nokogiri::XML::Builder.new(encoding: "utf-8") do |xml|
230
+ builder.send(:append_artsl, xml, limitations)
231
+ end.to_xml
232
+ end
233
+
234
+ it "emits an <ARTSL> block with one <ARTLIM> per indication code" do
235
+ data = Oddb2xml::FhirExtractor.new(cyramza_fixture).to_hash
236
+ lims = data.values.first[:packages].values.first[:limitations]
237
+ xml = artsl_xml(lims)
238
+
239
+ expect(xml).to include("<ARTSL>")
240
+ # PM is true because the BAG indication code is required only for SL
241
+ # price-model drugs, which is exactly what reaches this block.
242
+ expect(xml).to include("<PM>true</PM>")
243
+ expect(xml).to include("<LIMCD>CYRAMZA.01</LIMCD>")
244
+ expect(xml).to include("<INDCD>20403.01</INDCD>")
245
+ expect(xml).to include("<VDAT>2024-10-01T00:00:00</VDAT>")
246
+ expect(xml).to include("<LIMCD>CYRAMZA.02</LIMCD>")
247
+ expect(xml).to include("<INDCD>20403.02</INDCD>")
248
+ # End date present only for CYRAMZA.02 -> only that ARTLIM gets a <VTDAT>.
249
+ expect(xml).to include("<VTDAT>2027-09-30T00:00:00</VTDAT>")
250
+ expect(xml.scan("<VTDAT>").size).to eq(1)
251
+ end
252
+
253
+ it "omits <ARTSL> when no limitation carries an indication code" do
254
+ expect(artsl_xml([{code: "X", indcd: "", vdate: "2020-01-01"}])).not_to include("<ARTSL>")
255
+ expect(artsl_xml([])).not_to include("<ARTSL>")
256
+ expect(artsl_xml(nil)).not_to include("<ARTSL>")
257
+ end
258
+ end
212
259
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oddb2xml
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.25
4
+ version: 3.0.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yasuhiro Asaka, Zeno R.R. Davatz, Niklaus Giger
@@ -449,6 +449,7 @@ files:
449
449
  - CLAUDE.md
450
450
  - Elexis_Artikelstamm_v003.xsd
451
451
  - Elexis_Artikelstamm_v5.xsd
452
+ - Elexis_Artikelstamm_v6.xsd
452
453
  - Gemfile
453
454
  - Gemfile.lock
454
455
  - History.txt
@@ -496,8 +497,12 @@ files:
496
497
  - oddb2xml.gemspec
497
498
  - oddb2xml.xsd
498
499
  - oddb_calc.xsd
500
+ - scripts/generate_index_html.sh
499
501
  - scripts/run_oddb2xml.sh
502
+ - scripts/setup_mediupdatexml_web.sh
503
+ - scripts/swissmedic_watch.sh
500
504
  - scripts/transfer.sh
505
+ - scripts/visitor_stats.py
501
506
  - spec/artikelstamm_spec.rb
502
507
  - spec/builder_spec.rb
503
508
  - spec/calc_spec.rb