9add58fe83
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)
271 lines
11 KiB
Python
271 lines
11 KiB
Python
# 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 pos−1).
|
||
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) |