Files
odoo-at-payroll/personalverrechnung/tools/gen_sv_parameters_2026.py
T
fegger 42bb759fd1 [IMP] l10n_at_gemeinde_payroll: implement SV value layer (AP4) from OEGK TASY
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.
2026-09-09 12:47:10 +02:00

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())