[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,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)