[IMP] l10n_at_gemeinde_payroll: implement AP2 pay computation engine (GemBG Bgld.)

Entgelt-Engine nach Implementierungsplan AP2 (freigegeben 2026-09-09):

- Structure Type "Bgld. GemBG 2014" + Structure "Gemeindebedienstete"
  mit 4 Kategorien (GEMBG_BRUTTO, GEMBG_ABZUG, GEMBG_NETTO, GEMBG_DG).
- Work-Entry-Types: Überstunden Tag/Nacht (§ 76), Mehrdienstleistung,
  Journaldienst (§ 79), Bereitschaft (§ 80), Sonn-/Feiertag (§ 78).
- Input-Types: Erschwerniszulage (§ 83), Fahrtkostenzuschuss (§ 88),
  Jubiläumszuwendung (§ 89).
- Entgelt-Regeln (13 Rules):
  * ME — Monatsentgelt: Katalog-Lookup (Gruppe+Stufe+Stichtag),
    Ausbildungsphase 5 % Abzug (§ 133h), Teilzeit via work_time_rate.
  * KIZU — Kinderzulage: kinder_anzahl × 15,60 EUR (§ 61 GemBG),
    bei Teilzeit UNGEKÜRZT (§ 59 Abs 2).
  * FUZU — Funktionszulage: hr.version-Feld × work_time_rate (§ 62).
  * UEZ_TAG/UEZ_NACHT — Überstundenvergütung (§ 76):
    Grund = ME / (4,33 × Wochenstunden); Zuschlag 50 % Tag / 100 % Nacht.
  * JZU — Journaldienstzulage (§ 79): Work-Entry × Parametersatz.
  * SFZU — Sonn-/Feiertagszulage (§ 78): Stunden × Grundvergütung × %.
  * BERB — Bereitschaftsentschädigung (§ 80): Stunden × Parametersatz.
  * ERZU/FKZ/JUB — Input-basierte Bezüge (§§ 83/88/89).
  * SZQ — Sonderzahlung QUARTALSWEISE (§ 133n): in Quartalsendmonaten
    (März, Juni, September, Dezember) 50 % × (ME + KIZU).
  * NETTO — Kategoriensumme (wird mit AP3/AP4 erweitert).
- hr.version-Felder: kinder_anzahl, funktionszulage, ausbildungsphase
  (§ 61/§ 62/§ 133h) mit Employee-Spiegel nach Engine-Muster.
- Rule-Parameter: Kinderzulage 15,60 EUR, Journaldienstzulage,
  Sonn-/Feiertags-%, Bereitschaftsstunde (letzte 3 als AP0-Platzhalter
  mit 0.0 — Werte aus der Bezügeanpassung nach Beschaffung).
- Tests (8 Rechenfälle): Vollzeit av1 Stufe 1, Kinderzulage 2 Kinder
  = 31,20 EUR, Funktionszulage, Ausbildungsphase 5 % Abzug,
  SZ Quartalsende + Nicht-Quartalsende, Netto=Brutto (AP3/AP4 offen),
  Stufe 12 Max.
- Employee-Form um Zulagen-Gruppe erweitert.

Modul-Version 19.0.1.3.0. Statische Validierung grün; Runtime-Testlauf
auf dem Dev-Host ausständig.
This commit is contained in:
2026-09-09 13:01:00 +02:00
parent 42bb759fd1
commit a7959c1259
9 changed files with 627 additions and 2 deletions
@@ -1,3 +1,4 @@
# Part of the gem360 onboarding. See LICENSE / project docs for details.
from . import test_katalog
from . import test_tasy_import
from . import test_tasy_import
from . import test_entgelt
@@ -0,0 +1,117 @@
# Part of the gem360 onboarding. See LICENSE / project docs for details.
from datetime import date
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class EntgeltTest(TransactionCase):
"""AP2 Rechenfälle: Monatsentgelt, Kinderzulage, Funktionszulage,
Sonderzahlung (quartalsweise), Ausbildungsphase."""
def setUp(self):
super().setUp()
self.structure = self.env.ref(
'l10n_at_gemeinde_payroll.structure_gembg_bgld')
self.gruppe_av1 = self.env.ref(
'l10n_at_gemeinde_payroll.bgld_grp_av_av1')
self.parameter = self.env['hr.rule.parameter']
def _create_employee_with_payslip(self, month=None, **kwargs):
employee = self.env['hr.employee'].create({
'name': 'Test GemBG Entgelt',
})
version = self.env['hr.version'].search(
[('employee_id', '=', employee.id)], limit=1)
if not version:
version = self.env['hr.version'].create({
'employee_id': employee.id,
'date_start': date(2020, 1, 1),
})
version_vals = {
'entlohnungsgruppe_id': self.gruppe_av1.id,
'entlohnungsstufe': kwargs.get('stufe', 1),
}
version_vals.update(kwargs.get('version_vals', {}))
version.write(version_vals)
# Ausbildungsphase und Zulagen direkt auf Version
if 'ausbildungsphase' in kwargs:
version.ausbildungsphase = kwargs['ausbildungsphase']
if 'funktionszulage' in kwargs:
version.funktionszulage = kwargs['funktionszulage']
if 'kinder_anzahl' in kwargs:
version.kinder_anzahl = kwargs['kinder_anzahl']
slip_month = month or 7
slip = self.env['hr.payslip'].create({
'employee_id': employee.id,
'struct_id': self.structure.id,
'date_from': date(2026, slip_month, 1),
'date_to': date(2026, slip_month, 28),
})
slip.compute_sheet()
return employee, slip
def _get_rule_total(self, slip, code):
line = slip.line_ids.filtered(
lambda l: l.code == code)
return line.total if line else 0.0
def test_01_monatsentgelt_av1_stufe1(self):
"""Vollzeit av1 Stufe 1 = 7.135,20 € (Wert 1.7.2026)."""
_, slip = self._create_employee_with_payslip(month=7)
self.assertAlmostEqual(
self._get_rule_total(slip, 'ME'), 7135.20, places=2)
def test_02_kinderzulage_ungekuerzt_bei_teilzeit(self):
"""Kinderzulage: 2 Kinder = 31,20 €, auch bei Teilzeit."""
_, slip = self._create_employee_with_payslip(
month=7, kinder_anzahl=2)
# 2 × 15,60 = 31,20
self.assertAlmostEqual(
self._get_rule_total(slip, 'KIZU'), 31.20, places=2)
def test_03_funktionszulage(self):
"""Funktionszulage: 200 € am Vertrag."""
_, slip = self._create_employee_with_payslip(
month=7, funktionszulage=200.0)
self.assertAlmostEqual(
self._get_rule_total(slip, 'FUZU'), 200.0, places=2)
def test_04_ausbildungsphase_5_prozent_abzug(self):
"""§ 133h: 5 % Abzug auf Monatsentgelt."""
_, slip = self._create_employee_with_payslip(
month=7, ausbildungsphase=True)
# 7.135,20 × 0,95 = 6.778,44
self.assertAlmostEqual(
self._get_rule_total(slip, 'ME'), 6778.44, places=2)
def test_05_sonderzahlung_quartalsende(self):
"""SZ in September (Q3-Ende) = 50 % × (ME + KIZU)."""
_, slip = self._create_employee_with_payslip(
month=9, kinder_anzahl=1)
me = self._get_rule_total(slip, 'ME')
kizu = self._get_rule_total(slip, 'KIZU')
sz = self._get_rule_total(slip, 'SZQ')
self.assertAlmostEqual(sz, (me + kizu) * 0.5, places=2)
def test_06_sonderzahlung_kein_quartalsende(self):
"""Kein SZ außerhalb der Quartalsendmonate."""
_, slip = self._create_employee_with_payslip(month=7)
self.assertEqual(self._get_rule_total(slip, 'SZQ'), 0.0)
def test_07_netto_ohne_abzuege(self):
"""R1: Netto = Brutto (AP3/AP4 Abzüge noch nicht implementiert)."""
_, slip = self._create_employee_with_payslip(
month=7, kinder_anzahl=1, funktionszulage=100.0)
brutto = self._get_rule_total(slip, 'ME') \
+ self._get_rule_total(slip, 'KIZU') \
+ self._get_rule_total(slip, 'FUZU')
netto = self._get_rule_total(slip, 'NETTO')
self.assertAlmostEqual(netto, brutto, places=2)
def test_08_stufe_12_max(self):
"""Stufe 12 von av1 = 8.712,50 €."""
_, slip = self._create_employee_with_payslip(month=7, stufe=12)
self.assertAlmostEqual(
self._get_rule_total(slip, 'ME'), 8712.50, places=2)