#!/usr/bin/env python3 """Generate the 2026 SV rule-parameter seed from the ÖGK TASY-LSWH export. Source of truth: personalverrechnung/quellen/TASY-LSWH-Export-20260324.xml (ÖGK tariff system export, Gültigkeitstichtag 2026-07-01). Run from the repository root: python3 personalverrechnung/tools/gen_sv_parameters_2026.py Outputs ``addons/l10n_at_gemeinde_payroll/data/rule_parameters_2026.xml`` (hr.rule.parameter + hr.rule.parameter.value records) and prints a protocol with provenance for every value. Values are cross-checked against the official "Beitragsrechtliche Werte in der Sozialversicherung 2026" (sozialversicherung.at) anchors from personalverrechnung/ RECHTSQUELLEN-Bgld.md; the § 2a AMPFG AV-staffel is maintained from the official report (not contained in TASY) and flagged accordingly. Resolution: beschaeftigtengruppe B002 ("Angestellte") -> valid tarifgruppen -> tarifgruppeSetZuord -> tarifsets -> tariffs valid at the export's Stichtag; per Zweig the split variant (DN>0 and DG>0) wins over freie-Dienstnehmer variants. bbbwerte are cent values. """ from __future__ import annotations import sys import xml.etree.ElementTree as ET from pathlib import Path REPO = Path(__file__).resolve().parents[2] SOURCE = (REPO / "personalverrechnung" / "quellen" / "TASY-LSWH-Export-20260324.xml") TARGET = REPO / "addons" / "l10n_at_gemeinde_payroll" / "data" / "rule_parameters_2026.xml" BESCHAEFTIGTENGRUPPE_CODE = "B002" # Angestellte BBB_MAPPINGS = { # code -> (parameter, cent?, factor) "GFGBTG": ("at_sv_gfg_monatlich", True, 1), "HBGALLG": ("at_sv_hbg_monatlich", True, 30), # tgl. HBG x 30 (§ 45 Abs 1 ASVG) "HBGSZ": ("at_sv_hbg_sz_jaehrlich", True, 1), } ZWEIG_MAPPINGS = { # zweig -> parameter base code (_dn/_dg appended) "KV": "at_sv_kv", "PV": "at_sv_pv", "AV": "at_sv_av", "UV": "at_sv_uv", "IE": "at_sv_iesg", "BV": "at_sv_bv", "AK": "at_sv_ak", "WF": "at_sv_wf", } NAMES = { "at_sv_gfg_monatlich": "SV Geringfügigkeitsgrenze (monatlich, § 5 Abs 2 ASVG)", "at_sv_hbg_monatlich": "SV Höchstbeitragsgrundlage (monatlich, § 45 Abs 1 ASVG)", "at_sv_hbg_sz_jaehrlich": "SV Höchstbeitragsgrundlage Sonderzahlungen (§ 54 ASVG)", "at_sv_kv_dn": "SV Krankenversicherung Dienstnehmeranteil", "at_sv_kv_dg": "SV Krankenversicherung Dienstgeberanteil", "at_sv_pv_dn": "SV Pensionsversicherung Dienstnehmeranteil", "at_sv_pv_dg": "SV Pensionsversicherung Dienstgeberanteil", "at_sv_av_dn": "SV Arbeitslosenversicherung Dienstnehmeranteil", "at_sv_av_dg": "SV Arbeitslosenversicherung Dienstgeberanteil", "at_sv_uv_dn": "SV Unfallversicherung Dienstnehmeranteil", "at_sv_uv_dg": "SV Unfallversicherung Dienstgeberanteil", "at_sv_iesg_dn": "IESG-Zuschlag Dienstnehmeranteil", "at_sv_iesg_dg": "IESG-Zuschlag Dienstgeberanteil (Insolvenz)", "at_sv_bv_dn": "Betriebliche Vorsorge Dienstnehmeranteil", "at_sv_bv_dg": "Betriebliche Vorsorge Dienstgeberanteil", "at_sv_ak_dn": "Arbeiterkammerumlage Dienstnehmeranteil", "at_sv_ak_dg": "Arbeiterkammerumlage Dienstgeberanteil", "at_sv_wf_dn": "Wohnbauförderungsbeitrag Dienstnehmeranteil", "at_sv_wf_dg": "Wohnbauförderungsbeitrag Dienstgeberanteil", "at_sv_av_staffel_ampfg": "SV AV-Beitragsstaffel bei geringem Einkommen (§ 2a AMPFG)", } EXPECTED = { # anchors from the official SV-Werte-Report 2026 "at_sv_gfg_monatlich": 551.10, "at_sv_hbg_monatlich": 6930.00, "at_sv_hbg_sz_jaehrlich": 13860.00, "at_sv_kv_dn": 3.87, "at_sv_kv_dg": 3.78, "at_sv_pv_dn": 10.25, "at_sv_pv_dg": 12.55, "at_sv_av_dn": 2.95, "at_sv_av_dg": 2.95, "at_sv_uv_dg": 1.10, "at_sv_iesg_dg": 0.10, "at_sv_bv_dg": 1.53, } def text(node, tag): child = node.find(tag) return child.text.strip() if child is not None and child.text else "" def parse(path): root = ET.parse(path).getroot() stichtag = text(root, "gueltigkeitstichtag") tarife, sets, gruppen, zuords = {}, {}, {}, [] for t in root.findall(".//tarif"): tarife[t.get("xmlId")] = { "zweig": text(t, "tarifZweig"), "dn": float(text(t, "dienstnehmerAnteil")), "dg": float(text(t, "dienstgeberAnteil")), "von": text(t, "vonDatum"), "bis": text(t, "bisDatum"), } for s in root.findall(".//tarifset"): sets[s.get("xmlId")] = [c.text.strip() for c in s.findall("tarife")] for g in root.findall(".//beschaeftigtengruppe"): if text(g, "code") == BESCHAEFTIGTENGRUPPE_CODE: gruppen[g.get("xmlId")] = g for z in root.findall(".//tarifgruppeSetZuord"): zuords.append({ "tarifgruppe": text(z, "tarifgruppe"), "tarifset": text(z, "tarifset"), "von": text(z, "vonDatum"), "bis": text(z, "bisDatum"), }) tg_ids = {g.get("xmlId") for g in root.findall(".//tarifgruppe") if any(text(g, "beschaeftigtengruppen") == gid for gid in gruppen)} bbbs, bbb_werte = {}, [] for b in root.findall(".//beitragbemessungsbetrag"): bbbs[b.get("xmlId")] = text(b, "code") for w in root.findall(".//bbbwert"): bbb_werte.append({ "code": bbbs.get(text(w, "beitragbemessungsbetrag_id"), ""), "betrag": float(text(w, "betrag")), "von": text(w, "vonDatum"), "bis": text(w, "bisDatum"), }) return stichtag, tarife, sets, tg_ids, zuords, bbb_werte def main() -> int: stichtag, tarife, sets, tg_ids, zuords, bbb_werte = parse(SOURCE) results: dict[str, tuple[float, str]] = {} protocol: list[str] = [] for code, (parameter, cent, faktor) in BBB_MAPPINGS.items(): candidates = [w for w in bbb_werte if w["code"] == code and w["von"] <= stichtag <= w["bis"]] if not candidates: protocol.append(f"FEHLER: bbbwert {code} am Stichtag {stichtag} fehlt") continue w = max(candidates, key=lambda x: x["von"]) value = w["betrag"] * faktor / (100 if cent else 1) results[parameter] = (round(value, 2), w["von"]) protocol.append(f"{parameter} = {value:.2f} ({code}, ab {w['von']}" f"{', Cent' if cent else ''}" f"{f' x {faktor}' if faktor != 1 else ''})") valid_sets = {z["tarifset"] for z in zuords if z["tarifgruppe"] in tg_ids and z["von"] <= stichtag <= z["bis"]} for zweig, parameter in ZWEIG_MAPPINGS.items(): pairs = {} for sid in valid_sets: for tid in sets.get(sid, []): t = tarife.get(tid) if not t or t["zweig"] != zweig or not (t["von"] <= stichtag <= t["bis"]): continue pairs.setdefault((t["dn"], t["dg"]), t["von"]) split = [(dn, dg, von) for (dn, dg), von in pairs.items() if dn > 0 and dg > 0] employer = [(dn, dg, von) for (dn, dg), von in pairs.items() if dg > 0 and dn == 0] chosen = None if len(pairs) == 1: chosen = next(iter(pairs.items())) elif len(split) == 1: chosen = ((split[0][0], split[0][1]), split[0][2]) elif len(employer) == 1: # einseitige Arbeitgeberzweige (UV, BV) vor freie-DN-Varianten chosen = ((employer[0][0], employer[0][1]), employer[0][2]) if not chosen: protocol.append(f"WARNUNG: Zweig {zweig} nicht eindeutig auflösbar: {pairs}") continue (dn, dg), von = chosen results[f"{parameter}_dn"] = (dn, von) results[f"{parameter}_dg"] = (dg, von) protocol.append(f"{parameter}_dn = {dn} / _dg = {dg} ({zweig}, ab {von})") # § 2a AMPFG staffel: official SV-Werte-Report (not in TASY) staffel = ("{'tiers': [(0.0, 2225.00, 0.0), (2225.00, 2427.00, 1.0)," " (2427.00, 2630.00, 2.0)], 'av_total': 5.9}") results["at_sv_av_staffel_ampfg"] = (staffel, stichtag) protocol.append("at_sv_av_staffel_ampfg = Staffel 2225/2427/2630, AV gesamt 5.9 " "(SV-Werte-Report 2026, § 2a AMPFG — nicht aus TASY)") errors = [p for p in protocol if p.startswith("FEHLER") or p.startswith("WARNUNG")] for parameter, expected in EXPECTED.items(): if parameter not in results: errors.append(f"FEHLER: Erwartungswert {parameter} fehlt in Ergebnis") elif abs(results[parameter][0] - expected) > 0.005: errors.append( f"FEHLER: {parameter} = {results[parameter][0]} weicht vom " f"offiziellen Anker {expected} ab!") xml = ['', ""] for parameter in sorted(results): value, von = results[parameter] xml.append( f' \n' f' {NAMES[parameter]}\n' f' {parameter}\n' f' \n' f' ') xml.append( f' \n' f' \n' f' {von}\n' f' {value}\n' f' ') xml.append("") TARGET.write_text("\n".join(xml) + "\n", encoding="utf-8") print(f"Stichtag: {stichtag} · Quelle: {SOURCE.name}") print("\n".join(protocol)) print(f"\n{len(results)} Parameter nach {TARGET} geschrieben.") if errors: print("\n".join(errors)) return 1 print("Alle Erwartungswerte (SV-Werte-Report 2026) erfüllt.") return 0 if __name__ == "__main__": sys.exit(main())