42bb759fd1
SV-Werte-Implementierung nach dem freigegebenen TASY-Plan (PLAN-TASY-SV-Import):
- hr.rule.parameter-Seed 2026 (20 Parameter, generator-erzeugt aus dem
ÖGK TASY-LSWH-Export, Stichtag 2026-07-01): Geringfügigkeit 551,10 EUR
(GFGBTG, ab 2025 — Aufwertungsaussetzung korrekt abgebildet), HBG
monatlich 6.930 EUR (HBGALLG tgl. x 30), HBG SZ 13.860 EUR, KV
3,87/3,78, PV 10,25/12,55, AV 2,95/2,95, UV 0/1,10, IESG 0/0,10,
BV 1,53, AK/WF geführt, § 2a AMPFG-Staffel aus dem SV-Werte-Report.
Generator mit Herkunftsnachweis + Erwartungswert-Ankern
(personalverrechnung/tools/gen_sv_parameters_2026.py, Exit 1 bei
Abweichung von den offiziellen Ankern).
- TASY-Quelldatei committet (personalverrechnung/quellen/, 5 MB —
öffentliches Tarifdatenmaterial ohne Personendaten) für Seed- und
Testreproduzierbarkeit.
- Deklarative TASY-Mapping-Tabelle (gemeinde.payroll.tasy.mapping,
data XML): bbb-Codes (Cent, Faktor z. B. x 30) und Tarifzweige
(_dn/_dg-Suffix-Konvention) — keine festen Werte im Code.
- Import-Wizard (TransientModel, lxml safe parsing):
Beschaeftigtengruppe B002 ("Angestellte") -> Tarifgruppe ->
tarifgruppeSetZuord -> Tarifset -> Tarif; bei Varianten gewinnt die
Split-Variante (DN>0 und DG>0), dann die einseitige DG-Variante (UV/BV);
freie-DN-Varianten verlieren. Preview mit Delta, Sprungwarnung > 10 %,
Warnzeilen werden nie automatisch übernommen; Schreiben versionierter
hr.rule.parameter.value-Sätze erst nach expliziter Bestätigung.
- Tests (4 Suiten): Seed-Werte am Stichtag, Fixture-Parser
(Split-Präferenz, Cent-Rechnung), Versionsverhalten (Update statt
Duplikat), Echtdaten-Test gegen den 5-MB-Export (Auflösung == Seed,
keine Warnzeilen).
- Security/Views/Menüs für Mapping + Wizard; Modul-Version 19.0.1.2.0.
Statische Validierung grün (py_compile, XML, Manifest); Runtime-Testlauf
auf dem Dev-Host ausständig.
226 lines
9.8 KiB
Python
226 lines
9.8 KiB
Python
#!/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 = ['<?xml version="1.0" encoding="utf-8"?>', "<odoo>"]
|
|
for parameter in sorted(results):
|
|
value, von = results[parameter]
|
|
xml.append(
|
|
f' <record id="hr_rule_parameter_{parameter}" '
|
|
f'model="hr.rule.parameter">\n'
|
|
f' <field name="name">{NAMES[parameter]}</field>\n'
|
|
f' <field name="code">{parameter}</field>\n'
|
|
f' <field name="country_id" ref="base.at"/>\n'
|
|
f' </record>')
|
|
xml.append(
|
|
f' <record id="hr_rule_parameter_value_{parameter}" '
|
|
f'model="hr.rule.parameter.value">\n'
|
|
f' <field name="rule_parameter_id" '
|
|
f'ref="hr_rule_parameter_{parameter}"/>\n'
|
|
f' <field name="date_from">{von}</field>\n'
|
|
f' <field name="parameter_value">{value}</field>\n'
|
|
f' </record>')
|
|
xml.append("</odoo>")
|
|
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()) |