[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:
2026-09-09 12:47:10 +02:00
parent 8e0b169b73
commit 42bb759fd1
11 changed files with 94626 additions and 12 deletions
@@ -1,7 +1,7 @@
# Part of the gem360 onboarding. See LICENSE / project docs for details.
{
'name': 'Österreich - Gemeinde-Personalverrechnung (Bgld. GemBG)',
'version': '19.0.1.1.0',
'version': '19.0.1.2.0',
'author': 'ixsol / gem360',
'website': 'https://gem360.at',
'category': 'Human Resources/Payroll',
@@ -30,15 +30,20 @@ Gemeindebedienstetengesetz 2014** (LGBl. Nr. 42/2014):
Mehr-Bundesland-Architektur: Landes-Kataloge und landesspezifische
Regeln sind von Anfang an getrennt (Burgenland zuerst).
**Status:** AP1 (Katalogmodell) implementiert: Entlohnungsgruppen/-stufen
mit Jahresversionierung aus den Bezügeanpassungs-Tabellen (Seed 2026:
35 Gruppen, 433 Werte), GemBG-Felder auf der Payroll-Version
(Entlohnungsgruppe, Stufe, Besoldungsdienstalter, Vorrückungstermin
nach § 66 Abs 2) und Vorrückungs-Wizard mit Protokoll. Strukturen,
Regeln, Meldewesen und VRV-Buchung folgen dem freigegebenen
Implementierungsplan — Grundlagen: ``personalverrechnung/PLAN-payroll-bgld.md``,
**Status:** AP1 (Katalogmodell) und SV-Wertelayer (AP4-Basis, TASY) implementiert:
Entlohnungsgruppen/-stufen mit Jahresversionierung aus den Bezüge-
anpassungs-Tabellen (Seed 2026: 35 Gruppen, 433 Werte), GemBG-Felder auf
der Payroll-Version (Entlohnungsgruppe, Stufe, Besoldungsdienstalter,
Vorrückungstermin nach § 66 Abs 2) mit Vorrückungs-Wizard; SV-Werte 2026
als ``hr.rule.parameter``-Seed (aus ÖGK TASY-LSWH-Export generiert und
gegen den offiziellen SV-Werte-Report gespiegelt) inkl. deklarativer
TASY-Mapping-Tabelle und Import-Wizard (Preview, Sprungwarnung,
explizite Bestätigung, versionierte Werte). Strukturen, Regeln,
Meldewesen und VRV-Buchung folgen dem freigegebenen Implementierungsplan —
Grundlagen: ``personalverrechnung/PLAN-payroll-bgld.md``,
``personalverrechnung/RECHTSQUELLEN-Bgld.md``,
``personalverrechnung/IMPLEMENTIERUNGSPLAN-Bgld.md`` und der
``personalverrechnung/IMPLEMENTIERUNGSPLAN-Bgld.md``,
``personalverrechnung/PLAN-TASY-SV-Import.md`` und der
``payroll``-Agent-Skill.
""",
'depends': ['hr_payroll', 'hr_payroll_account', 'l10n_at_vrv2015'],
@@ -46,8 +51,11 @@ Implementierungsplan — Grundlagen: ``personalverrechnung/PLAN-payroll-bgld.md`
'security/ir.model.access.csv',
'data/gemeinde.payroll.entlohnungsgruppe.csv',
'data/gemeinde.payroll.entlohnungsstufe.csv',
'data/rule_parameters_2026.xml',
'data/tasy_mapping.xml',
'views/entlohnung_views.xml',
'views/hr_version_bgld_views.xml',
'views/tasy_views.xml',
],
'countries': ['at'],
'license': 'LGPL-3',
@@ -0,0 +1,203 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_rule_parameter_at_sv_ak_dg" model="hr.rule.parameter">
<field name="name">Arbeiterkammerumlage Dienstgeberanteil</field>
<field name="code">at_sv_ak_dg</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_ak_dg" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_ak_dg"/>
<field name="date_from">2019-01-01</field>
<field name="parameter_value">0.0</field>
</record>
<record id="hr_rule_parameter_at_sv_ak_dn" model="hr.rule.parameter">
<field name="name">Arbeiterkammerumlage Dienstnehmeranteil</field>
<field name="code">at_sv_ak_dn</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_ak_dn" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_ak_dn"/>
<field name="date_from">2019-01-01</field>
<field name="parameter_value">0.5</field>
</record>
<record id="hr_rule_parameter_at_sv_av_dg" model="hr.rule.parameter">
<field name="name">SV Arbeitslosenversicherung Dienstgeberanteil</field>
<field name="code">at_sv_av_dg</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_av_dg" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_av_dg"/>
<field name="date_from">2024-01-01</field>
<field name="parameter_value">2.95</field>
</record>
<record id="hr_rule_parameter_at_sv_av_dn" model="hr.rule.parameter">
<field name="name">SV Arbeitslosenversicherung Dienstnehmeranteil</field>
<field name="code">at_sv_av_dn</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_av_dn" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_av_dn"/>
<field name="date_from">2024-01-01</field>
<field name="parameter_value">2.95</field>
</record>
<record id="hr_rule_parameter_at_sv_av_staffel_ampfg" model="hr.rule.parameter">
<field name="name">SV AV-Beitragsstaffel bei geringem Einkommen (§ 2a AMPFG)</field>
<field name="code">at_sv_av_staffel_ampfg</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_av_staffel_ampfg" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_av_staffel_ampfg"/>
<field name="date_from">2026-07-01</field>
<field name="parameter_value">{'tiers': [(0.0, 2225.00, 0.0), (2225.00, 2427.00, 1.0), (2427.00, 2630.00, 2.0)], 'av_total': 5.9}</field>
</record>
<record id="hr_rule_parameter_at_sv_bv_dg" model="hr.rule.parameter">
<field name="name">Betriebliche Vorsorge Dienstgeberanteil</field>
<field name="code">at_sv_bv_dg</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_bv_dg" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_bv_dg"/>
<field name="date_from">2019-01-01</field>
<field name="parameter_value">1.53</field>
</record>
<record id="hr_rule_parameter_at_sv_bv_dn" model="hr.rule.parameter">
<field name="name">Betriebliche Vorsorge Dienstnehmeranteil</field>
<field name="code">at_sv_bv_dn</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_bv_dn" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_bv_dn"/>
<field name="date_from">2019-01-01</field>
<field name="parameter_value">0.0</field>
</record>
<record id="hr_rule_parameter_at_sv_gfg_monatlich" model="hr.rule.parameter">
<field name="name">SV Geringfügigkeitsgrenze (monatlich, § 5 Abs 2 ASVG)</field>
<field name="code">at_sv_gfg_monatlich</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_gfg_monatlich" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_gfg_monatlich"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">551.1</field>
</record>
<record id="hr_rule_parameter_at_sv_hbg_monatlich" model="hr.rule.parameter">
<field name="name">SV Höchstbeitragsgrundlage (monatlich, § 45 Abs 1 ASVG)</field>
<field name="code">at_sv_hbg_monatlich</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_hbg_monatlich" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_hbg_monatlich"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">6930.0</field>
</record>
<record id="hr_rule_parameter_at_sv_hbg_sz_jaehrlich" model="hr.rule.parameter">
<field name="name">SV Höchstbeitragsgrundlage Sonderzahlungen (§ 54 ASVG)</field>
<field name="code">at_sv_hbg_sz_jaehrlich</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_hbg_sz_jaehrlich" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_hbg_sz_jaehrlich"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">13860.0</field>
</record>
<record id="hr_rule_parameter_at_sv_iesg_dg" model="hr.rule.parameter">
<field name="name">IESG-Zuschlag Dienstgeberanteil (Insolvenz)</field>
<field name="code">at_sv_iesg_dg</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_iesg_dg" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_iesg_dg"/>
<field name="date_from">2022-01-01</field>
<field name="parameter_value">0.1</field>
</record>
<record id="hr_rule_parameter_at_sv_iesg_dn" model="hr.rule.parameter">
<field name="name">IESG-Zuschlag Dienstnehmeranteil</field>
<field name="code">at_sv_iesg_dn</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_iesg_dn" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_iesg_dn"/>
<field name="date_from">2022-01-01</field>
<field name="parameter_value">0.0</field>
</record>
<record id="hr_rule_parameter_at_sv_kv_dg" model="hr.rule.parameter">
<field name="name">SV Krankenversicherung Dienstgeberanteil</field>
<field name="code">at_sv_kv_dg</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_kv_dg" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_kv_dg"/>
<field name="date_from">2019-01-01</field>
<field name="parameter_value">3.78</field>
</record>
<record id="hr_rule_parameter_at_sv_kv_dn" model="hr.rule.parameter">
<field name="name">SV Krankenversicherung Dienstnehmeranteil</field>
<field name="code">at_sv_kv_dn</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_kv_dn" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_kv_dn"/>
<field name="date_from">2019-01-01</field>
<field name="parameter_value">3.87</field>
</record>
<record id="hr_rule_parameter_at_sv_pv_dg" model="hr.rule.parameter">
<field name="name">SV Pensionsversicherung Dienstgeberanteil</field>
<field name="code">at_sv_pv_dg</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_pv_dg" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_pv_dg"/>
<field name="date_from">2019-01-01</field>
<field name="parameter_value">12.55</field>
</record>
<record id="hr_rule_parameter_at_sv_pv_dn" model="hr.rule.parameter">
<field name="name">SV Pensionsversicherung Dienstnehmeranteil</field>
<field name="code">at_sv_pv_dn</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_pv_dn" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_pv_dn"/>
<field name="date_from">2019-01-01</field>
<field name="parameter_value">10.25</field>
</record>
<record id="hr_rule_parameter_at_sv_uv_dg" model="hr.rule.parameter">
<field name="name">SV Unfallversicherung Dienstgeberanteil</field>
<field name="code">at_sv_uv_dg</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_uv_dg" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_uv_dg"/>
<field name="date_from">2023-01-01</field>
<field name="parameter_value">1.1</field>
</record>
<record id="hr_rule_parameter_at_sv_uv_dn" model="hr.rule.parameter">
<field name="name">SV Unfallversicherung Dienstnehmeranteil</field>
<field name="code">at_sv_uv_dn</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_uv_dn" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_uv_dn"/>
<field name="date_from">2023-01-01</field>
<field name="parameter_value">0.0</field>
</record>
<record id="hr_rule_parameter_at_sv_wf_dg" model="hr.rule.parameter">
<field name="name">Wohnbauförderungsbeitrag Dienstgeberanteil</field>
<field name="code">at_sv_wf_dg</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_wf_dg" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_wf_dg"/>
<field name="date_from">2019-01-01</field>
<field name="parameter_value">0.5</field>
</record>
<record id="hr_rule_parameter_at_sv_wf_dn" model="hr.rule.parameter">
<field name="name">Wohnbauförderungsbeitrag Dienstnehmeranteil</field>
<field name="code">at_sv_wf_dn</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sv_wf_dn" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_wf_dn"/>
<field name="date_from">2019-01-01</field>
<field name="parameter_value">0.5</field>
</record>
</odoo>
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Beitragsbemessungsbeträge (Cent-Werte, TASY-LSWH) -->
<record id="tasy_mapping_gfg" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">bbb</field>
<field name="tasy_code">GFGBTG</field>
<field name="parameter_code">at_sv_gfg_monatlich</field>
<field name="cent_werte" eval="True"/>
<field name="faktor">1.0</field>
</record>
<record id="tasy_mapping_hbg_mon" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">bbb</field>
<field name="tasy_code">HBGALLG</field>
<field name="parameter_code">at_sv_hbg_monatlich</field>
<field name="cent_werte" eval="True"/>
<field name="faktor">30</field>
</record>
<record id="tasy_mapping_hbg_sz" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">bbb</field>
<field name="tasy_code">HBGSZ</field>
<field name="parameter_code">at_sv_hbg_sz_jaehrlich</field>
<field name="cent_werte" eval="True"/>
<field name="faktor">1.0</field>
</record>
<!-- Tarifzweige (Beitragssätze; _dn/_dg wird angehängt) -->
<record id="tasy_mapping_kv" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">zweig</field>
<field name="tasy_code">KV</field>
<field name="parameter_code">at_sv_kv</field>
</record>
<record id="tasy_mapping_pv" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">zweig</field>
<field name="tasy_code">PV</field>
<field name="parameter_code">at_sv_pv</field>
</record>
<record id="tasy_mapping_av" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">zweig</field>
<field name="tasy_code">AV</field>
<field name="parameter_code">at_sv_av</field>
</record>
<record id="tasy_mapping_uv" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">zweig</field>
<field name="tasy_code">UV</field>
<field name="parameter_code">at_sv_uv</field>
</record>
<record id="tasy_mapping_iesg" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">zweig</field>
<field name="tasy_code">IE</field>
<field name="parameter_code">at_sv_iesg</field>
</record>
<record id="tasy_mapping_bv" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">zweig</field>
<field name="tasy_code">BV</field>
<field name="parameter_code">at_sv_bv</field>
</record>
<record id="tasy_mapping_ak" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">zweig</field>
<field name="tasy_code">AK</field>
<field name="parameter_code">at_sv_ak</field>
</record>
<record id="tasy_mapping_wf" model="gemeinde.payroll.tasy.mapping">
<field name="tasy_art">zweig</field>
<field name="tasy_code">WF</field>
<field name="parameter_code">at_sv_wf</field>
</record>
</odoo>
@@ -1,3 +1,4 @@
# Part of the gem360 onboarding. See LICENSE / project docs for details.
from . import entlohnung
from . import hr_version_bgld
from . import hr_version_bgld
from . import tasy
@@ -0,0 +1,291 @@
# Part of the gem360 onboarding. See LICENSE / project docs for details.
from lxml import etree
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class GemeindePayrollTasyMapping(models.Model):
"""Mapping TASY-Code/-Zweig -> SV-Regelparameter (hr.rule.parameter).
Hält die Zuordnung deklarativ (data XML), damit der TASY-Import ohne
feste Werte im Code funktioniert und für künftige Export-Formate
(z. B. BVAEB) erweiterbar bleibt. Für Tarifzweige wird an den
Parameter-Code ``_dn``/``_dg`` angehängt.
"""
_name = 'gemeinde.payroll.tasy.mapping'
_description = 'TASY-Mapping (SV-Regelparameter)'
_order = 'tasy_art, tasy_code'
tasy_art = fields.Selection(
[('bbb', 'Beitragsbemessungsbetrag (Grenzwert)'),
('zweig', 'Tarifzweig (Beitragssatz)')],
required=True)
tasy_code = fields.Char(required=True, index=True)
parameter_code = fields.Char(
required=True, index=True,
help="Code des hr.rule.parameter; bei Tarifzweigen wird _dn/_dg "
"angehängt.")
cent_werte = fields.Boolean(
default=True, string='Cent-Werte',
help="TASY speichert Beitragsbemessungsbeträge in Cent.")
faktor = fields.Float(
default=1.0,
help="Multiplikator, z. B. 30 für die monatliche HBG aus dem "
"täglichen Wert (§ 45 Abs 1 ASVG).")
active = fields.Boolean(default=True)
_unique_mapping = models.Constraint(
'unique (tasy_art, tasy_code)',
"Je TASY-Art und -Code gibt es nur ein Mapping.")
@api.depends('tasy_art', 'tasy_code', 'parameter_code')
def _compute_display_name(self):
art_label = dict(self._fields['tasy_art'].selection)
for mapping in self:
art = art_label.get(mapping.tasy_art, '')
mapping.display_name = (
f"{mapping.tasy_code}{mapping.parameter_code}"
f" ({art})" if art else mapping.tasy_code)
class GemeindePayrollTasyImportLine(models.TransientModel):
_name = 'gemeinde.payroll.tasy.import.line'
_description = 'TASY-Import: Vorschauzeile'
wizard_id = fields.Many2one(
'gemeinde.payroll.tasy.import.wizard', required=True, ondelete='cascade')
parameter_code = fields.Char(required=True)
name = fields.Char()
alter_wert = fields.Char(string='Alter Wert', readonly=True)
neuer_wert = fields.Char(string='Neuer Wert', required=True)
date_from = fields.Date(required=True)
uebernehmen = fields.Boolean(string='Übernehmen', default=True)
warnung = fields.Char(help="Leer = eindeutig aufgelöst; sonst "
"Plausibilitäts-/Auflösungshinweis. "
"Warnzeilen werden nie automatisch übernommen.")
class GemeindePayrollTasyImportWizard(models.TransientModel):
"""Lädt einen jährlichen ÖGK TASY-LSWH-Export und schreibt nach
expliziter Bestätigung SV-Werte als hr.rule.parameter-Werte
(Versionierung über date_from, historische Werte bleiben erhalten).
Auflösung am Gültigkeitstichtag: Beitragsbemessungsbeträge direkt
über den Code; Beitragssätze über die Kette Beschäftigtengruppe
(Standard: B002 „Angestellte") → Tarifgruppe → tarifgruppeSetZuord →
Tarifset → Tarif. Bei mehreren Varianten gewinnt die Split-Variante
(DN>0 und DG>0), danach die einseitige Dienstgeber-Variante (UV/BV);
freie-Dienstnehmer-Varianten (nur DN) verlieren. Mehrdeutige
Auflösungen erzeugen Warnzeilen ohne automatische Übernahme.
"""
_name = 'gemeinde.payroll.tasy.import.wizard'
_description = 'SV-Werte aus TASY-LSWH-Export importieren'
file = fields.Binary(required=True, string='TASY-Export (XML)')
filename = fields.Char()
stichtag = fields.Date(readonly=True,
help="Gültigkeitstichtag des Exports.")
beschaeftigtengruppe_code = fields.Char(
default='B002', required=True,
help="TASY-Code der Beschäftigtengruppe (B002 = Angestellte).")
line_ids = fields.One2many(
'gemeinde.payroll.tasy.import.line', 'wizard_id',
string='Vorschau')
protocol = fields.Text(readonly=True)
# ------------------------------------------------------------------
# Parsing
# ------------------------------------------------------------------
def _tasy_root(self):
self.ensure_one()
parser = etree.XMLParser(resolve_entities=False, no_network=True)
return etree.fromstring(self.file, parser)
@staticmethod
def _tasy_text(node, tag):
child = node.find(tag)
return (child.text or '').strip() if child is not None else ''
def _parse_bbbwerte(self, root):
codes = {b.get('xmlId'): self._tasy_text(b, 'code')
for b in root.iter('beitragbemessungsbetrag')}
werte = []
for w in root.iter('bbbwert'):
werte.append({
'code': codes.get(self._tasy_text(w, 'beitragbemessungsbetrag_id')),
'betrag': float(self._tasy_text(w, 'betrag')),
'von': self._tasy_text(w, 'vonDatum'),
'bis': self._tasy_text(w, 'bisDatum'),
})
return werte
def _parse_tarife(self, root):
return {t.get('xmlId'): {
'zweig': self._tasy_text(t, 'tarifZweig'),
'dn': float(self._tasy_text(t, 'dienstnehmerAnteil')),
'dg': float(self._tasy_text(t, 'dienstgeberAnteil')),
'von': self._tasy_text(t, 'vonDatum'),
'bis': self._tasy_text(t, 'bisDatum'),
} for t in root.iter('tarif')}
def _resolve_zweig_pairs(self, root, stichtag):
"""Standard-Beitragssätze je Zweig über die Gruppenkette."""
self.ensure_one()
gruppen_ids = {g.get('xmlId') for g in root.iter('beschaeftigtengruppe')
if self._tasy_text(g, 'code') == self.beschaeftigtengruppe_code}
tarifgruppen = {tg.get('xmlId') for tg in root.iter('tarifgruppe')
if any(self._tasy_text(tg, 'beschaeftigtengruppen') == gid
for gid in gruppen_ids)}
sets = {s.get('xmlId'): [t.text.strip() for t in s.iter('tarife')]
for s in root.iter('tarifset')}
valid_sets = {self._tasy_text(z, 'tarifset')
for z in root.iter('tarifgruppeSetZuord')
if self._tasy_text(z, 'tarifgruppe') in tarifgruppen
and self._tasy_text(z, 'vonDatum') <= stichtag
<= self._tasy_text(z, 'bisDatum')}
tarife = self._parse_tarife(root)
pairs = {}
for sid in valid_sets:
for tid in sets.get(sid, []):
t = tarife.get(tid)
if not t or not (t['von'] <= stichtag <= t['bis']):
continue
pairs.setdefault(t['zweig'], {}).setdefault(
(t['dn'], t['dg']), t['von'])
return pairs
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_parse(self):
self.ensure_one()
root = self._tasy_root()
stichtag = (root.findtext('gueltigkeitstichtag') or '').strip()
if not stichtag:
raise UserError(_('Ungültiger TASY-Export: '
'Gültigkeitstichtag fehlt.'))
self.stichtag = stichtag
lines, protocol = self._build_lines(root, stichtag)
self.line_ids = [(5, 0, 0)] + lines
self.protocol = "\n".join(protocol)
return True
def _build_lines(self, root, stichtag):
self.ensure_one()
line_vals, protocol = [], []
parameter_obj = self.env['hr.rule.parameter']
bbbwerte = self._parse_bbbwerte(root)
for mapping in self.env['gemeinde.payroll.tasy.mapping'].search(
[('tasy_art', '=', 'bbb')]):
candidates = [w for w in bbbwerte if w['code'] == mapping.tasy_code
and w['von'] <= stichtag <= w['bis']]
if not candidates:
protocol.append(
f"KEIN WERT: {mapping.tasy_code} "
f"({mapping.parameter_code}) am Stichtag")
continue
w = max(candidates, key=lambda x: x['von'])
value = w['betrag'] * mapping.faktor
if mapping.cent_werte:
value /= 100.0
line_vals.append(self._line_vals(
mapping.parameter_code, round(value, 2), w['von'],
parameter_obj, protocol))
pairs = self._resolve_zweig_pairs(root, stichtag)
for mapping in self.env['gemeinde.payroll.tasy.mapping'].search(
[('tasy_art', '=', 'zweig')]):
variants = pairs.get(mapping.tasy_code, {})
chosen = None
split = [(dn, dg, von) for (dn, dg), von in variants.items()
if dn > 0 and dg > 0]
employer = [(dn, dg, von) for (dn, dg), von in variants.items()
if dg > 0 and dn == 0]
if len(variants) == 1:
(dn, dg), von = next(iter(variants.items()))
chosen = (dn, dg, von)
elif len(split) == 1:
chosen = split[0]
elif len(employer) == 1:
chosen = employer[0]
if not chosen:
protocol.append(
f"NICHT EINDEUTIG: Zweig {mapping.tasy_code} "
f"({mapping.parameter_code}): {dict(variants)}")
for (dn, dg), von in variants.items():
line_vals.append(self._line_vals(
f"{mapping.parameter_code}_dn", dn, von, parameter_obj,
protocol, warnung=f"Mehrdeutig: DN {dn} / DG {dg}"))
continue
dn, dg, von = chosen
line_vals.append(self._line_vals(
f"{mapping.parameter_code}_dn", dn, von, parameter_obj, protocol))
line_vals.append(self._line_vals(
f"{mapping.parameter_code}_dg", dg, von, parameter_obj, protocol))
return line_vals, protocol
def _line_vals(self, code, value, date_from, parameter_obj, protocol,
warnung=None):
self.ensure_one()
old = parameter_obj._get_parameter_from_code(code, date_from,
raise_if_not_found=False)
old_value = old.parameter_value if old else ''
old_float = None
try:
old_float = float(old_value)
except (TypeError, ValueError):
old_float = None
if old_float and abs(value - old_float) / old_float > 0.10:
warnung = (warnung or '') + f" Sprung > 10 % ({old_value}{value})"
return (0, 0, {
'parameter_code': code,
'name': parameter_obj.search(
[('code', '=', code)]).name or code,
'alter_wert': old_value,
'neuer_wert': repr(value),
'date_from': date_from,
'uebernehmen': not warnung,
'warnung': warnung or '',
})
def action_apply(self):
self.ensure_one()
parameter_obj = self.env['hr.rule.parameter']
value_obj = self.env['hr.rule.parameter.value']
country = self.env.ref('base.at')
applied = 0
for line in self.line_ids.filtered('uebernehmen'):
parameter = parameter_obj.search(
[('code', '=', line.parameter_code)], limit=1)
if not parameter:
parameter = parameter_obj.create({
'name': line.name or line.parameter_code,
'code': line.parameter_code,
'country_id': country.id,
})
value = value_obj.search([
('rule_parameter_id', '=', parameter.id),
('date_from', '=', line.date_from)], limit=1)
if value:
value.parameter_value = line.neuer_wert
else:
value_obj.create({
'rule_parameter_id': parameter.id,
'date_from': line.date_from,
'parameter_value': line.neuer_wert,
})
applied += 1
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'message': _("%(count)s Parameterwert(e) geschrieben.",
count=applied),
'next': {'type': 'ir.actions.act_window_close'},
},
}
@@ -2,4 +2,8 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_gemeinde_payroll_entlohnungsgruppe_user,Gemeinde Payroll Entlohnungsgruppe User,model_gemeinde_payroll_entlohnungsgruppe,hr_payroll.group_hr_payroll_user,1,0,0,0
access_gemeinde_payroll_entlohnungsgruppe_manager,Gemeinde Payroll Entlohnungsgruppe Manager,model_gemeinde_payroll_entlohnungsgruppe,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_gemeinde_payroll_entlohnungsstufe_user,Gemeinde Payroll Entlohnungsstufe User,model_gemeinde_payroll_entlohnungsstufe,hr_payroll.group_hr_payroll_user,1,0,0,0
access_gemeinde_payroll_entlohnungsstufe_manager,Gemeinde Payroll Entlohnungsstufe Manager,model_gemeinde_payroll_entlohnungsstufe,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_gemeinde_payroll_entlohnungsstufe_manager,Gemeinde Payroll Entlohnungsstufe Manager,model_gemeinde_payroll_entlohnungsstufe,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_gemeinde_payroll_tasy_mapping_user,Gemeinde Payroll TASY-Mapping User,model_gemeinde_payroll_tasy_mapping,hr_payroll.group_hr_payroll_user,1,0,0,0
access_gemeinde_payroll_tasy_mapping_manager,Gemeinde Payroll TASY-Mapping Manager,model_gemeinde_payroll_tasy_mapping,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_gemeinde_payroll_tasy_import_wizard,Gemeinde Payroll TASY-Import Wizard,model_gemeinde_payroll_tasy_import_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
access_gemeinde_payroll_tasy_import_line,Gemeinde Payroll TASY-Import Line,model_gemeinde_payroll_tasy_import_line,hr_payroll.group_hr_payroll_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_gemeinde_payroll_entlohnungsgruppe_user Gemeinde Payroll Entlohnungsgruppe User model_gemeinde_payroll_entlohnungsgruppe hr_payroll.group_hr_payroll_user 1 0 0 0
3 access_gemeinde_payroll_entlohnungsgruppe_manager Gemeinde Payroll Entlohnungsgruppe Manager model_gemeinde_payroll_entlohnungsgruppe hr_payroll.group_hr_payroll_manager 1 1 1 1
4 access_gemeinde_payroll_entlohnungsstufe_user Gemeinde Payroll Entlohnungsstufe User model_gemeinde_payroll_entlohnungsstufe hr_payroll.group_hr_payroll_user 1 0 0 0
5 access_gemeinde_payroll_entlohnungsstufe_manager Gemeinde Payroll Entlohnungsstufe Manager model_gemeinde_payroll_entlohnungsstufe hr_payroll.group_hr_payroll_manager 1 1 1 1
6 access_gemeinde_payroll_tasy_mapping_user Gemeinde Payroll TASY-Mapping User model_gemeinde_payroll_tasy_mapping hr_payroll.group_hr_payroll_user 1 0 0 0
7 access_gemeinde_payroll_tasy_mapping_manager Gemeinde Payroll TASY-Mapping Manager model_gemeinde_payroll_tasy_mapping hr_payroll.group_hr_payroll_manager 1 1 1 1
8 access_gemeinde_payroll_tasy_import_wizard Gemeinde Payroll TASY-Import Wizard model_gemeinde_payroll_tasy_import_wizard hr_payroll.group_hr_payroll_user 1 1 1 1
9 access_gemeinde_payroll_tasy_import_line Gemeinde Payroll TASY-Import Line model_gemeinde_payroll_tasy_import_line hr_payroll.group_hr_payroll_user 1 1 1 1
@@ -1,2 +1,3 @@
# Part of the gem360 onboarding. See LICENSE / project docs for details.
from . import test_katalog
from . import test_katalog
from . import test_tasy_import
@@ -0,0 +1,194 @@
# Part of the gem360 onboarding. See LICENSE / project docs for details.
import base64
from datetime import date
from odoo.tests import TransactionCase, tagged
# Minimaler TASY-LSWH-Strukturslice für Parser-Tests (B002 „Angestellte",
# KV Split- + freie-DN-Variante, GFGBTG in Cent).
TASY_FIXTURE = """<?xml version="1.0" encoding="UTF-8"?>
<tarifssystemlsh>
<gueltigkeitstichtag>2026-07-01</gueltigkeitstichtag>
<beitragbemessungsbetraege>
<beitragbemessungsbetrag xmlId="B100">
<code>GFGBTG</code>
<bezeichnung>Test Geringfügigkeitsgrenze</bezeichnung>
<vonDatum>2019-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
</beitragbemessungsbetrag>
</beitragbemessungsbetraege>
<bbbwerte>
<bbbwert xmlId="W100">
<beitragbemessungsbetrag_id>B100</beitragbemessungsbetrag_id>
<betrag>55110</betrag>
<vonDatum>2025-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
</bbbwert>
</bbbwerte>
<beschaeftigtengruppen>
<beschaeftigtengruppe xmlId="G200">
<code>B002</code>
<bezeichnung>Angestellte</bezeichnung>
<vonDatum>2019-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
</beschaeftigtengruppe>
</beschaeftigtengruppen>
<tarifgruppen>
<tarifgruppe xmlId="T300">
<beschaeftigtengruppen>G200</beschaeftigtengruppen>
<code>B002</code>
<vonDatum>2019-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
</tarifgruppe>
</tarifgruppen>
<tarife>
<tarif xmlId="K400">
<vonDatum>2019-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
<dienstgeberAnteil>3.78</dienstgeberAnteil>
<dienstnehmerAnteil>3.87</dienstnehmerAnteil>
<tarifZweig>KV</tarifZweig>
</tarif>
<tarif xmlId="K401">
<vonDatum>2019-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
<dienstgeberAnteil>0</dienstgeberAnteil>
<dienstnehmerAnteil>7.65</dienstnehmerAnteil>
<tarifZweig>KV</tarifZweig>
</tarif>
<tarif xmlId="U402">
<vonDatum>2023-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
<dienstgeberAnteil>1.1</dienstgeberAnteil>
<dienstnehmerAnteil>0</dienstnehmerAnteil>
<tarifZweig>UV</tarifZweig>
</tarif>
</tarife>
<tarifsets>
<tarifset xmlId="S500">
<bezeichnung>Test Standard</bezeichnung>
<vonDatum>2019-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
<tarife>K400</tarife>
<tarife>U402</tarife>
</tarifset>
<tarifset xmlId="S501">
<bezeichnung>Test freie DN</bezeichnung>
<vonDatum>2019-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
<tarife>K401</tarife>
</tarifset>
</tarifsets>
<tarifgruppeSetZuordnungen>
<tarifgruppeSetZuord xmlId="Z600">
<tarifgruppe>T300</tarifgruppe>
<tarifset>S500</tarifset>
<vonDatum>2019-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
</tarifgruppeSetZuord>
<tarifgruppeSetZuord xmlId="Z601">
<tarifgruppe>T300</tarifgruppe>
<tarifset>S501</tarifset>
<vonDatum>2019-01-01</vonDatum>
<bisDatum>2999-12-31</bisDatum>
</tarifgruppeSetZuord>
</tarifgruppeSetZuordnungen>
</tarifssystemlsh>
"""
@tagged('post_install', '-at_install')
class TasyImportTest(TransactionCase):
"""TASY-Import: Seed-Werte, Parser/Resolution, Schreibverhalten."""
def test_01_seed_parameter_values(self):
"""Seed 2026 (aus echtem TASY-Export generiert) am Stichtag."""
parameter = self.env['hr.rule.parameter']
stichtag = date(2026, 7, 1)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_kv_dn', stichtag).parameter_value),
3.87)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_kv_dg', stichtag).parameter_value),
3.78)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_gfg_monatlich', stichtag).parameter_value),
551.10)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_hbg_monatlich', stichtag).parameter_value),
6930.00)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_uv_dg', stichtag).parameter_value),
1.10)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_iesg_dg', stichtag).parameter_value),
0.10)
def test_02_wizard_parse_fixture(self):
"""Fixture: bbb Cent-Werte + Split-Variante gewinnt über freie DN."""
wizard = self.env['gemeinde.payroll.tasy.import.wizard'].create({
'file': base64.b64encode(TASY_FIXTURE.encode()).decode(),
'filename': 'fixture.xml',
})
wizard.action_parse()
self.assertEqual(wizard.stichtag, date(2026, 7, 1))
lines = {line.parameter_code: line for line in wizard.line_ids}
self.assertIn('at_sv_gfg_monatlich', lines)
self.assertEqual(float(lines['at_sv_gfg_monatlich'].neuer_wert), 551.10)
# KV: Split-Variante 3.87/3.78 statt freier DN 7.65/0
self.assertEqual(float(lines['at_sv_kv_dn'].neuer_wert), 3.87)
self.assertEqual(float(lines['at_sv_kv_dg'].neuer_wert), 3.78)
# UV: einseitige DG-Variante
self.assertEqual(float(lines['at_sv_uv_dg'].neuer_wert), 1.10)
# eindeutige Auflösungen sind zur Übernahme vorgeschlagen
self.assertTrue(lines['at_sv_kv_dn'].uebernehmen)
self.assertFalse(lines['at_sv_kv_dn'].warnung)
def test_03_wizard_apply_versions(self):
"""Übernehmen schreibt versionierte Werte; Update am Stichtag."""
wizard = self.env['gemeinde.payroll.tasy.import.wizard'].create({
'file': base64.b64encode(TASY_FIXTURE.encode()).decode(),
'filename': 'fixture.xml',
})
wizard.action_parse()
wizard.action_apply()
value = self.env['hr.rule.parameter.value'].search([
('code', '=', 'at_sv_gfg_monatlich'),
('date_from', '=', date(2025, 1, 1))], limit=1)
self.assertTrue(value)
self.assertEqual(float(value.parameter_value), 551.10)
# erneuter Import am selben Stichtag aktualisiert statt zu duplizieren
wizard2 = self.env['gemeinde.payroll.tasy.import.wizard'].create({
'file': base64.b64encode(TASY_FIXTURE.encode()).decode(),
'filename': 'fixture.xml',
})
wizard2.action_parse()
wizard2.action_apply()
count = self.env['hr.rule.parameter.value'].search_count([
('code', '=', 'at_sv_gfg_monatlich'),
('date_from', '=', date(2025, 1, 1))])
self.assertEqual(count, 1)
def test_04_wizard_real_export(self):
"""Echter TASY-Export aus personalverrechnung/quellen (5 MB):
Auflösung muss die offiziellen 2026er Werte liefern."""
import os
path = os.path.join(os.path.dirname(__file__), *[
'..', '..', '..', 'personalverrechnung', 'quellen',
'TASY-LSWH-Export-20260324.xml'])
if not os.path.exists(path):
self.skipTest('TASY-Quelldatei nicht im Workingtree')
with open(path, 'rb') as fh:
wizard = self.env['gemeinde.payroll.tasy.import.wizard'].create({
'file': base64.b64encode(fh.read()).decode(),
'filename': 'TASY-LSWH-Export-20260324.xml',
})
wizard.action_parse()
lines = {line.parameter_code: line for line in wizard.line_ids}
self.assertEqual(wizard.stichtag, date(2026, 7, 1))
self.assertEqual(float(lines['at_sv_kv_dn'].neuer_wert), 3.87)
self.assertEqual(float(lines['at_sv_pv_dg'].neuer_wert), 12.55)
self.assertEqual(float(lines['at_sv_hbg_sz_jaehrlich'].neuer_wert), 13860.00)
# Seed liegt bereits auf denselben Stichtagen -> keine Warnzeilen
for line in wizard.line_ids:
self.assertFalse(line.warnung, line.warnung)
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="tasy_mapping_list_view" model="ir.ui.view">
<field name="name">gemeinde.payroll.tasy.mapping.list</field>
<field name="model">gemeinde.payroll.tasy.mapping</field>
<field name="arch" type="xml">
<list string="TASY-Mappings">
<field name="tasy_art"/>
<field name="tasy_code"/>
<field name="parameter_code"/>
<field name="cent_werte"/>
<field name="faktor"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="tasy_mapping_form_view" model="ir.ui.view">
<field name="name">gemeinde.payroll.tasy.mapping.form</field>
<field name="model">gemeinde.payroll.tasy.mapping</field>
<field name="arch" type="xml">
<form string="TASY-Mapping">
<sheet>
<group>
<group>
<field name="tasy_art"/>
<field name="tasy_code"/>
<field name="parameter_code"/>
</group>
<group>
<field name="cent_werte" invisible="tasy_art != 'bbb'"/>
<field name="faktor" invisible="tasy_art != 'bbb'"/>
<field name="active" widget="boolean_toggle"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_tasy_mapping" model="ir.actions.act_window">
<field name="name">TASY-Mappings (SV-Werte)</field>
<field name="res_model">gemeinde.payroll.tasy.mapping</field>
<field name="view_mode">list,form</field>
</record>
<record id="tasy_import_wizard_form_view" model="ir.ui.view">
<field name="name">gemeinde.payroll.tasy.import.wizard.form</field>
<field name="model">gemeinde.payroll.tasy.import.wizard</field>
<field name="arch" type="xml">
<form string="SV-Werte aus TASY-LSWH-Export importieren">
<group>
<field name="file" filename="filename"/>
<field name="filename" invisible="1"/>
<field name="beschaeftigtengruppe_code"/>
<field name="stichtag" readonly="1"/>
</group>
<notebook>
<page string="Vorschau" name="vorschau">
<field name="line_ids">
<list editable="bottom" create="0" delete="0">
<field name="uebernehmen" widget="boolean_toggle"/>
<field name="parameter_code"/>
<field name="name"/>
<field name="alter_wert"/>
<field name="neuer_wert"/>
<field name="date_from"/>
<field name="warnung"
decoration-danger="warnung"/>
</list>
</field>
</page>
<page string="Protokoll" name="protokoll">
<field name="protocol"/>
</page>
</notebook>
<footer>
<button string="1) Datei analysieren" name="action_parse"
type="object" class="btn-secondary"/>
<button string="2) Ausgewählte Werte übernehmen"
name="action_apply" type="object"
class="btn-primary" invisible="not line_ids"/>
<button string="Abbrechen" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_tasy_import_wizard" model="ir.actions.act_window">
<field name="name">SV-Werte importieren (TASY)</field>
<field name="res_model">gemeinde.payroll.tasy.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<menuitem id="menu_gemeinde_payroll_tasy_mapping"
name="TASY-Mappings (SV-Werte)"
parent="menu_gemeinde_payroll_root"
action="action_tasy_mapping" sequence="40"/>
<menuitem id="menu_gemeinde_payroll_tasy_import"
name="SV-Werte importieren (TASY)"
parent="menu_gemeinde_payroll_root"
action="action_tasy_import_wizard" sequence="50"/>
</odoo>
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())