# 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)