Files
odoo-at-payroll/addons/l10n_at_hr_payroll/tests/test_elda_l16.py
T
fegger 9add58fe83 [IMP] l10n_at_hr_payroll_private: add statutory old-system severance (AP15-A)
Pay the pre-2003 AngG severance from the end-of-employment run as its own neutral benefit line: free of SV (§ 49 Abs 3 Z 7 ASVG) and payroll levies, outside the annual sixth, and collected into the § 291d EO termination pot net of its own wage tax. Taxation follows the binding better-of rule of § 67 Abs 3 EStG: multiple-of-method (tariff wage tax on the running monthly wage × the statutory multiple of the payout) versus the flat 6 % rate, applied mandatorily in favour of the employee. The tariff wage tax is captured as a separately confirmed value instead of deriving it from gross pay through the § 62 engine. The wizard enforces § 23a Abs 3 AngG (parental exit needs five uninterrupted years), blocks BMSVG-new contracts except frozen claims and validates AGRD 02/06 against the claim type. The LNK basis subtracts § 67 Abs 3/6 rules only when they actually run in the tax base, the L16 generator reports LST_ABF in key 260 outside 210/220, and result_rules access in the garnishment paths avoids DefaultDictPayroll materialisation. Verified against the original statute texts in .ris/ (AngG §§ 23/23a, EStG § 67 Abs 3).

Version 19.0.13.0.0 (private), 19.0.9.0.0 (core)
2026-09-15 11:01:55 +02:00

271 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Part of the odoo-at-payroll project. License: LGPL-3.
"""GP7 Kern: ELDA-Fixsatz-Framework (DM-ORG 42.7.0 Konventionen) und
Lohnzettel-Finanz-Generator (I1/L1, BEST LF/94) — Positionsexakte
Satz-Assertions, CRLF/ISO-8859-15-Encoding, SANR/SANZ-Kette,
KZ-245-Formel-Konsistenz aus GP3-Daten."""
import base64
import datetime
import types
from odoo.tests import TransactionCase, tagged
from odoo.addons.l10n_at_hr_payroll.models.elda import (
L10N_AT_ELDA_IDTEIL,
L10nAtEldaBestand,
L10nAtEldaDatei,
L10nAtEldaSatz,
l10n_at_euro_cent,
)
@tagged('post_install', '-at_install')
class EldaFrameworkTest(TransactionCase):
def test_01_feldformatierung(self):
"""a/n: linksbündig blank-gefüllt; n: rechtsbündig mit führenden
Nullen, keine Interpunktion; Cent kaufmännisch; skala=3 für
Prozentsätze (39,30 % → 039300)."""
satz = L10nAtEldaSatz(
[(1, 'A', 1, 5, 'a'), (2, 'N', 6, 4, 'n'),
(3, 'B', 10, 10, 'n')], 20)
satz.setze('A', 'AB')
satz.setze('N', 42)
self.assertEqual(satz.render()[:5], 'AB ')
self.assertEqual(satz.render()[5:9], '0042')
# Unbelegtes n-Feld: Grundstellung 0.
self.assertEqual(satz.render()[9:19], '0' * 10)
self.assertEqual(l10n_at_euro_cent(1234.567), 123457)
self.assertEqual(l10n_at_euro_cent(0.005), 1)
prozent = L10nAtEldaSatz(
[(1, 'VPTA', 1, 6, 'n')], 6)
prozent.setze('VPTA', 39.30, skala=3)
self.assertEqual(prozent.render(), '039300')
def test_02_bestand_datei(self):
"""Vorlaufsatz SART 00 mit BEST/VSTR/VERS, SANR lückenlos ab 1,
SANZ inklusive Vorlauf/Schluss, CRLF-getrennte Zeilen in
ISO-8859-15, optionaler Dateiheader ENCD."""
satz1 = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + [(2, 'X', 21, 4, 'a')], 24,
bezeichnung='Test')
satz1.setze('SART', '45')
satz1.setze('VSTR', 'ST')
satz1.setze('X', 'TEST')
satz2 = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + [(2, 'Y', 21, 4, 'n')], 24,
bezeichnung='Test')
satz2.setze('SART', '45')
satz2.setze('VSTR', 'ST')
satz2.setze('Y', 7)
bestand = L10nAtEldaBestand(
'AD', 'ST', '02',
hersteller={'name': 'Test', 'kfz': 'A'},
datensaetze=[satz1, satz2])
zeilen = bestand.render(
erstellungsdatum=datetime.date(2026, 1, 15))
vorlauf = zeilen[0]
self.assertEqual(len(zeilen), 4)
self.assertEqual(vorlauf[18:20], 'ST') # VSTR (Pos. 19)
self.assertEqual(vorlauf[22:24], 'AD') # BEST
self.assertEqual(vorlauf[30:38], '15012026') # EDAT TTMMJJJJ
self.assertEqual(vorlauf[149:151], '02') # VERS
self.assertEqual(zeilen[1][0:2], '45')
self.assertEqual(zeilen[1][2:9], '0000002') # SANR
self.assertEqual(zeilen[3][20:26], '000004') # SANZ
datei = L10nAtEldaDatei([bestand])
roh = datei.render()
self.assertIn(b'\r\n', roh)
erste = roh.split(b'\r\n')[0]
self.assertEqual(erste[:4], b' 01')
self.assertIn(b'ISO-8859-15', erste)
# Umlaut in ISO-8859-15 codiert.
satz3 = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + [(2, 'Z', 21, 10, 'a')], 30,
bezeichnung='Umlaut')
satz3.setze('SART', '45')
satz3.setze('VSTR', 'ST')
satz3.setze('Z', 'Für')
self.assertIn('Für'.encode('ISO-8859-15'),
satz3.render().encode('ISO-8859-15'))
@tagged('post_install', '-at_install')
class L16GeneratorTest(TransactionCase):
def setUp(self):
super().setUp()
self.company = self.env.company
self.company.write({
'l10n_at_lohnsteuernummer': '123456789',
'l10n_at_beitragskontonummer': '1412345678',
})
self.structure = self.env.ref(
'l10n_at_hr_payroll_private.structure_at_privat')
self.kv2203 = self.env.ref(
'l10n_at_hr_payroll_private.kv_si_2203')
self.gruppe_ii = self.env.ref(
'l10n_at_hr_payroll_private.kv2203_grp_ii')
self.calendar = self.env['resource.calendar'].create({
'name': 'AT Test 40h',
'tz': 'Europe/Vienna',
'hours_per_day': 8,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd),
'hour_from': 8.0, 'hour_to': 16.0})
for wd in range(5)
],
})
self.company.resource_calendar_id = self.calendar
employee = self.env['hr.employee'].create({
'name': 'L16 Test', 'birthday': '1990-05-15'})
version = self.env['hr.version'].search(
[('employee_id', '=', employee.id)], limit=1)
version.write({
'contract_date_start': datetime.date(2020, 1, 1),
'ssnid': '1234150590',
'sex': 'female',
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'resource_calendar_id': self.calendar.id,
})
self.employee = employee
self.version = version
def _slip(self, monat, jahr=2026, confirm=True):
erster = datetime.date(jahr, monat, 1)
letzter = (erster.replace(day=28) + datetime.timedelta(days=4)) \
.replace(day=1) - datetime.timedelta(days=1)
slip = self.env['hr.payslip'].create({
'name': 'L16 %d/%d' % (monat, jahr),
'employee_id': self.employee.id,
'struct_id': self.structure.id,
'date_from': erster,
'date_to': letzter,
})
slip.compute_sheet()
if confirm:
slip.action_payslip_done()
return slip
def test_01_l16_lauf_und_positionen(self):
"""Jahreslauf: KZ-Salden aus GP3/GP4-Flags, L1-Record mit
exakten Positionen (Satzlänge 3500, REFN/ARTL/KZ-V/B-Paare,
SOZS/AVLN/AGBD), I1 (1100, STNRA/JAHR/GESA/ANZA), Datei mit
LF-Bestand VSTR 94, KZ-245-Konsistenz per Konstruktion."""
self._slip(11) # November mit B13
deklaration = self.env['l10n.at.payroll.l16'].create({
'company_id': self.company.id,
'jahr': 2026,
'art': '01',
})
deklaration.action_erzeugen()
self.assertEqual(deklaration.state, 'done')
self.assertEqual(len(deklaration.line_ids), 1)
line = deklaration.line_ids
# November: KZ 210 = KVGEO + B13 = 4.118,82 (§ 25 gesamt);
# KZ 220 = B13 = 2.059,41; KZ 245 = laufende Bemessung.
self.assertAlmostEqual(line.kz210, 4118.82, places=2)
self.assertAlmostEqual(line.kz220, 2059.41, places=2)
# KZ-245-Formel: 210 215 220 230 243
erwartet = (line.kz210 - line.kz215 - line.kz220
- line.kz230 - line.kz243)
self.assertAlmostEqual(line.kz245, erwartet, places=1)
# Datei: I1 (1100) + L1 (3500) + Vorlauf/Schluss (auf 3500
# aufgefüllt), LF-Bestand, VSTR 94, Lohnzettelversion 28.
roh = base64.b64decode(deklaration.datei_l16).decode(
'ISO-8859-15')
zeilen = roh.strip('\r\n').split('\r\n')
# Dateiheader + Vorlauf + I1 + L1 + Schluss.
self.assertEqual(len(zeilen), 5)
header, vorlauf, i1, l1, schluss = zeilen
self.assertEqual(header[:4], ' 01')
self.assertIn('ISO-8859-15', header)
self.assertEqual(len(i1), 1100)
self.assertEqual(len(l1), 3500)
self.assertEqual(vorlauf[0:2], '00')
self.assertEqual(vorlauf[18:20], '94')
self.assertEqual(vorlauf[22:24], 'LF')
self.assertEqual(vorlauf[149:151], '28')
self.assertEqual(i1[18:20], '94')
self.assertEqual(i1[20:21], 'I')
self.assertEqual(i1[34:43], '123456789') # STNRL
self.assertEqual(i1[50:59], '123456789') # STNRA
self.assertEqual(i1[82:84], '03') # STVE
self.assertEqual(i1[232:236], '2026') # JAHR
self.assertEqual(i1[236:243], '0000001') # GESA
self.assertEqual(l1[18:20], '94')
self.assertEqual(l1[20:21], 'L')
self.assertEqual(l1[95:97], '01') # ARTL
self.assertEqual(l1[119:123], '0111') # BELZ TTMM
self.assertEqual(l1[123:127], '3011') # ENLZ
self.assertEqual(l1[127:131], '2026') # JALZ
self.assertEqual(l1[131:132], '3') # SOZS Angestellte
self.assertEqual(l1[132:136], '1234') # AVLN LLLP
self.assertEqual(l1[136:142], '150590') # AGBD TTMMJJ
# KZ-Felder: V/B-Paare (1-based-Positionen → Index pos1).
self.assertEqual(l1[296:297], '+') # V210 (Pos. 297)
self.assertEqual(l1[297:307], '0000411882') # B210
self.assertEqual(l1[318:319], '+') # V220 (Pos. 319)
self.assertEqual(l1[319:329], '0000205941') # B220
self.assertEqual(l1[450:451], '+') # V245 (Pos. 451)
b245 = '%010d' % int(round(line.kz245 * 100))
self.assertEqual(l1[451:461], b245)
self.assertEqual(schluss[0:2], '99')
self.assertEqual(schluss[20:26], '000004') # SANZ
def test_01b_abfertigung_lohnsteuer_salden(self):
"""Abs.-3-Bezug bleibt außerhalb KZ 210/220; LST_ABF zählt zu 260."""
deklaration = self.env['l10n.at.payroll.l16'].create({
'company_id': self.company.id,
'jahr': 2026,
'art': '01',
})
l16_line = self.env['l10n.at.payroll.l16.line'].create({
'l16_id': deklaration.id,
'employee_id': self.employee.id,
})
abs3_rule = types.SimpleNamespace(
l10n_at_bemessung='sonstig', l10n_at_s67_abs='abs3')
lst_rule = types.SimpleNamespace(
l10n_at_bemessung='neutral', l10n_at_s67_abs='keine')
slip = types.SimpleNamespace(
line_ids=[
types.SimpleNamespace(
salary_rule_id=abs3_rule, code='ABF_ALT', total=12000.0),
types.SimpleNamespace(
salary_rule_id=lst_rule, code='LST_ABF', total=720.0),
],
_l10n_at_freibetraege_68=lambda: {'szfn': 0.0, 'ue_std': 0.0},
)
salden = l16_line._kz_salden([slip])
self.assertEqual(salden['kz210'], 0.0)
self.assertEqual(salden['kz220'], 0.0)
self.assertEqual(salden['lohnsteuer'], 720.0)
def test_02_arbeitsstaette(self):
"""§ 34 Abs 6 ASVG: je AN ein Satz 45 (Länge 300) mit BKNR,
VSNR, STNR und GKZ aus dem Dienstort-Partner; Warnung, wenn
die GKZ fehlt."""
self._slip(1)
deklaration = self.env['l10n.at.payroll.l16'].create({
'company_id': self.company.id,
'jahr': 2026,
'art': '01',
})
deklaration.action_erzeugen()
self.assertTrue(deklaration.datei_arbeitsstaette)
roh = base64.b64decode(
deklaration.datei_arbeitsstaette).decode('ISO-8859-15')
zeilen = roh.strip('\r\n').split('\r\n')
vorlauf, satz45 = zeilen[1], zeilen[2]
self.assertEqual(vorlauf[18:20], 'ST')
self.assertEqual(vorlauf[22:24], 'AD')
self.assertEqual(satz45[0:2], '45')
self.assertEqual(len(satz45), 300)
self.assertEqual(satz45[22:32], '1412345678') # BKNR
self.assertEqual(satz45[32:42], '1234150590') # VSNR
self.assertEqual(satz45[52:61], '123456789') # STNR
self.assertIn('Gemeindekennziffer', deklaration.protokoll)