[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.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user