[IMP] l10n_at_hr_payroll_private: add end-of-employment payroll cycle (AP14-A)

Pay the untaken vacation, dismissal compensation, time balances and open advances from one exit run. The vacation replacement splits natively into a running and a special-payment component per § 67 Abs 8 lit d, only the running part extends the mandatory insurance under § 11 Abs 2 ASVG with sequential windows (dismissal compensation first) and daily-value ceilings. Dismissal compensation follows the one-fifth rule of § 67 Abs 8 lit b without raising the annual sixth, stays fully social-insurable by allocation over the notice period and fully subject to payroll taxes. Advances are offset against net pay only (§ 293 Abs 3 EO) and time credits carry the 50 % statutory surcharge without § 68 relief. Verified against the original statute texts in .ris/; § 67 Abs 6 lump-sum payments are deliberately deferred to AP15.
This commit is contained in:
2026-09-14 16:08:44 +02:00
parent 9ff977e0d4
commit ee94f4664f
14 changed files with 1189 additions and 4 deletions
@@ -17,3 +17,4 @@ from . import test_sachbezuege_private
from . import test_sachbezuege2_private
from . import test_reisekosten_private
from . import test_pendler_private
from . import test_endabrechnung_private
@@ -0,0 +1,298 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP14-A: Endabrechnungs-Zyklus — UEL-Zweikomponenten (§ 67 Abs 8
lit d), SV-Verlängerung (§ 11 Abs 2 ASVG, sequentielle Fenster),
Abgangsentschädigung (§ 67 Abs 8 lit b Fünftel), Aufrechnung offener
Vorschüsse (§ 293 Abs 3 EO) und Zeitguthaben (§ 19e Abs 2 AZG)."""
from datetime import date, timedelta
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class EndabrechnungPrivateTest(TransactionCase):
def setUp(self):
super().setUp()
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 End 40h Mo-Fr',
'tz': 'Europe/Vienna',
'hours_per_day': 8,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % weekday,
'dayofweek': str(weekday),
'hour_from': 8.0, 'hour_to': 16.0})
for weekday in range(5)
],
})
self.env.company.resource_calendar_id = self.calendar
def _calendar(self, weekdays):
return self.env['resource.calendar'].create({
'name': 'AT Test End %dTage' % len(weekdays),
'tz': 'Europe/Vienna',
'hours_per_day': 8,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % weekday,
'dayofweek': str(weekday),
'hour_from': 8.0, 'hour_to': 16.0})
for weekday in weekdays
],
})
def _employee(self, ueberzahlung=2640.0,
ende=date(2026, 3, 15), calendar=None):
employee = self.env['hr.employee'].create(
{'name': 'Test End privat'})
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.write({
'contract_date_start': date(2020, 1, 1),
'contract_date_end': ende,
'ueberzahlung': ueberzahlung,
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'resource_calendar_id': (calendar or self.calendar).id,
})
return employee
def _slip(self, employee, monat=3, jahr=2026):
erster = date(jahr, monat, 1)
naechster = (erster + timedelta(days=32)).replace(day=1)
slip = self.env['hr.payslip'].create({
'name': 'Test-End %d/%d' % (monat, jahr),
'employee_id': employee.id,
'struct_id': self.structure.id,
'date_from': erster,
'date_to': naechster - timedelta(days=1),
})
slip.compute_sheet()
return slip
def _input(self, slip, code, amount):
typ = self.env['hr.payslip.input.type'].search(
[('code', '=', code)], limit=1)
self.env['hr.payslip.input'].create({
'payslip_id': slip.id,
'input_type_id': typ.id,
'amount': amount,
})
slip.compute_sheet()
def _total(self, slip, code):
return sum(slip.line_ids.filtered(
lambda line: line.code == code).mapped('total'))
def _basis_monate(self, slip):
return dict(slip._l10n_at_sv_basis_laufen_monate())
# ------------------------------------------------------------------
# RF-END-1: UEL-Zweikomponenten (§ 67 Abs 8 lit d)
# ------------------------------------------------------------------
def test_01_uel_zwei_komponenten(self):
"""11 offene AT à 120 € (2.640/22): UEL-lfd 1.320 € läuft
tarifisch und sechstelerhöhend; UEL-SZ 220 € (2×2.640/12/22)
als sonstiger Bezug; nur der laufende Anteil verlängert die
Pflichtversicherung."""
emp_mit = self._employee()
emp_ohne = self._employee()
slip_mit = self._slip(emp_mit)
slip_ohne = self._slip(emp_ohne)
self._input(slip_mit, 'ATP_UEL_TAGE', 11)
self.assertAlmostEqual(
self._total(slip_mit, 'URL_ERSATZ_LFD'), 1320.0, places=2)
self.assertAlmostEqual(
self._total(slip_mit, 'URL_ERSATZ_SZ'), 220.0, places=2)
svdn_diff = (slip_mit._l10n_at_svdn_gesamt()
- slip_ohne._l10n_at_svdn_gesamt())
self.assertAlmostEqual(
slip_mit._l10n_at_bemessung_laufen()
- slip_ohne._l10n_at_bemessung_laufen() + svdn_diff,
1320.0, places=2)
# Sechstel wächst um den annualisierten laufenden Anteil
# (60/Lohnsteuertage × 1.320; Teilmonat März: 15 Tage).
self.assertAlmostEqual(
slip_mit._l10n_at_sechstel() - slip_ohne._l10n_at_sechstel(),
1320.0 * 60.0 / 15.0, places=2)
# SZ-Bemessung enthält den UEL-SZ-Anteil (SV-DN nach § 67
# Abs 12 abziehen, um den Brutto-Anteil zu isolieren)
svdn_sz_diff = (slip_mit._l10n_at_svdn_sz()
- slip_ohne._l10n_at_svdn_sz())
self.assertAlmostEqual(
slip_mit._l10n_at_sz_bemessung()
- slip_ohne._l10n_at_sz_bemessung() + svdn_sz_diff,
220.0, places=2)
# ------------------------------------------------------------------
# RF-END-2: SV-Tage-Umrechnung und Verlängerungsmonate (§ 11 Abs 2)
# ------------------------------------------------------------------
def test_02_sv_tage_und_verlaengerung(self):
"""Schema je voller Woche à N AT + (7N) KT: 23 AT (5-Woche)
→ 31 SV-Tage; 12 WT (6-Woche) → 14; 6 AT (2-Tage-Woche) → 21
(lb-url-15). Ende 15. 3.: Verlängerung 16. 3.15. 4. — die
UEL-lfd-Basis liegt tageproportional auf den Monaten."""
emp = self._employee()
slip = self._slip(emp)
self.assertEqual(slip._at_end_sv_tage(23), 31)
self.assertEqual(slip._at_end_sv_tage(10), 14)
emp_6 = self._employee(calendar=self._calendar(range(6)))
self.assertEqual(self._slip(emp_6)._at_end_sv_tage(12), 14)
emp_2 = self._employee(calendar=self._calendar([0, 3]))
self.assertEqual(self._slip(emp_2)._at_end_sv_tage(6), 21)
self.assertEqual(
slip._at_end_verlaengerungs_monate(
31, date(2026, 3, 16)),
[(date(2026, 3, 1), 16), (date(2026, 4, 1), 15)])
self._input(slip, 'ATP_UEL_TAGE', 23)
basis = self._basis_monate(slip)
self.assertAlmostEqual(
basis[date(2026, 4, 1)], 2760.0 * 15.0 / 31.0, places=2)
# März: KVGEO-Standardanteil + UEL-März-Anteil
self.assertAlmostEqual(
basis[date(2026, 3, 1)] - 2760.0 * 16.0 / 31.0,
2640.0 * 10.0 / 22.0, places=2)
# ------------------------------------------------------------------
# RF-END-3: Abgangsentschädigung (§ 67 Abs 8 lit b, Fünftel)
# ------------------------------------------------------------------
def test_03_abgang_fuenftel(self):
"""9.000 € Abgangsentschädigung: Fünftel-LSt (1/5 steuerfrei
nach §-62-Abzug, Deckel 12.474 €), 4/5 tarifisch OHNE
Sechstelerhöhung; SV-Umlegung auf die 42 Tage Kündigungsfrist;
voll LNK-pflichtig."""
emp_mit = self._employee(ueberzahlung=3000.0)
emp_ohne = self._employee(ueberzahlung=3000.0)
slip_mit = self._slip(emp_mit)
slip_ohne = self._slip(emp_ohne)
self._input(slip_mit, 'ATP_ABGANG', 9000.0)
self._input(slip_mit, 'ATP_ABGANG_FFRIST_TAGE', 42)
self.assertAlmostEqual(self._total(slip_mit, 'ABGANG'), 9000.0,
places=2)
# Sechstel unverändert (Abs 2 nicht anzuwenden)
self.assertAlmostEqual(
slip_mit._l10n_at_sechstel() - slip_ohne._l10n_at_sechstel(),
0.0, places=2)
# Tarifischer Teil: 9.000 minus §-62-Abzug, 1/5 frei
satz = sum(slip_mit._l10n_at_sv_satz(
'dn', k, staffel_basis=slip_mit._l10n_at_sv_basis_roh())
for k in ('kv', 'pv', 'av', 'ak', 'wf'))
bemessung = 9000.0 * (1.0 - satz / 100.0)
deckel = float(slip_mit._l10n_at_param('at_sv_hbg_monatlich')) \
* 9.0 / 5.0
erwartet = bemessung - min(bemessung / 5.0, deckel)
self.assertAlmostEqual(
slip_mit._at_fuenftel_tarif_basis(), erwartet, places=2)
svdn_diff = (slip_mit._l10n_at_svdn_gesamt()
- slip_ohne._l10n_at_svdn_gesamt())
self.assertAlmostEqual(
slip_mit._l10n_at_bemessung_laufen()
- slip_ohne._l10n_at_bemessung_laufen() + svdn_diff,
erwartet, places=2)
# LNK-Basis um die volle Entschädigung erhöht
self.assertAlmostEqual(
slip_mit._at_lnk_basis('flaf')
- slip_ohne._at_lnk_basis('flaf'), 9000.0, places=2)
# SV-Umlegung: 16. 3.26. 4. (Kündigungsfrist zuerst); im März
# liegt zusätzlich der aliquote KVGEO-Standardanteil
basis = self._basis_monate(slip_mit)
self.assertAlmostEqual(
basis[date(2026, 4, 1)], 9000.0 * 26.0 / 42.0, places=2)
self.assertAlmostEqual(
basis[date(2026, 3, 1)] - 3000.0 * 10.0 / 22.0,
9000.0 * 16.0 / 42.0, places=2)
# ------------------------------------------------------------------
# RF-END-4: Aufrechnung offener Vorschüsse (§ 293 Abs 3 EO)
# ------------------------------------------------------------------
def test_04_aufrechnung_vorschuss(self):
"""2.000 € offener Vorschuss wird vom Netto abgezogen — ohne
jede Wirkung auf LSt-, SV- oder LNK-Basen (lb-end-06)."""
emp_mit = self._employee()
emp_ohne = self._employee()
slip_mit = self._slip(emp_mit)
slip_ohne = self._slip(emp_ohne)
self._input(slip_mit, 'ATP_ABRECHNUNG_VORSCHUSS', 2000.0)
self.assertAlmostEqual(self._total(slip_mit, 'END_ABRECHNUNG_VORSCHUSS'),
2000.0, places=2)
self.assertAlmostEqual(
slip_mit._l10n_at_bemessung_laufen()
- slip_ohne._l10n_at_bemessung_laufen(), 0.0, places=2)
self.assertAlmostEqual(
slip_mit._l10n_at_sv_basis_roh()
- slip_ohne._l10n_at_sv_basis_roh(), 0.0, places=2)
self.assertAlmostEqual(
slip_mit._at_lnk_basis('flaf')
- slip_ohne._at_lnk_basis('flaf'), 0.0, places=2)
self.assertAlmostEqual(
self._total(slip_mit, 'NET') - self._total(slip_ohne, 'NET'),
-2000.0, places=2)
# ------------------------------------------------------------------
# RF-END-5: Zeitguthaben (§ 19e Abs 2 AZG) und Zeitschulden
# ------------------------------------------------------------------
def test_05_zeitguthaben_und_schulden(self):
"""10 h Normalarbeitszeit-Guthaben + 50 % Zuschlag, 5 h
Zeitschulden negativ — beides laufender Bezug, KEIN §-68-
Freibetrag (lb-end-10)."""
emp = self._employee(ueberzahlung=3000.0)
slip = self._slip(emp)
self._input(slip, 'ATP_ZEIT_GUTHABEN', 10.0)
self._input(slip, 'ATP_ZEIT_SCHULDEN', 5.0)
stunde = slip._at_grundstunde_norm()
self.assertAlmostEqual(
self._total(slip, 'ZEIT_GUTHABEN'), 10.0 * stunde * 1.5,
places=2)
self.assertAlmostEqual(
self._total(slip, 'ZEIT_SCHULDEN'), -5.0 * stunde, places=2)
regel = slip.struct_id.rule_ids.filtered(
lambda rule: rule.code == 'ZEIT_GUTHABEN')
self.assertEqual(regel.l10n_at_steuerbefreiung_68, 'keine')
# ------------------------------------------------------------------
# RF-END-6: Wizard legt den Endabrechnungs-Lauf vorbefüllt an
# ------------------------------------------------------------------
def test_06_wizard(self):
"""Der Wizard sammelt UEL, Abgang, Zeitguthaben und Vorschuss
und erzeugt den Payslip mit vorbefüllten Eingaben."""
emp = self._employee()
# Version auf die private Structure-Type binden, damit der
# Wizard über version.struct_id die richtige Struktur findet.
emp.version_id.write({'structure_type_id': self.env.ref(
'l10n_at_hr_payroll_private.structure_type_at_privat').id})
wizard = self.env['l10n.at.payroll.endabrechnung.wizard'].create({
'employee_id': emp.id,
'austrittsdatum': date(2026, 3, 15),
'beendigungsart': 'einvernehmlich',
'uel_tage': 11.0,
'abgang_betrag': 9000.0,
'abgang_ffrist_tage': 42.0,
'zeit_guthaben_stunden': 10.0,
'vorschuss_saldo': 500.0,
})
ergebnis = wizard.action_payslip()
slip = self.env['hr.payslip'].browse(ergebnis['res_id'])
codes = set(slip.input_line_ids.mapped(
lambda line: line.input_type_id.code))
self.assertIn('ATP_UEL_TAGE', codes)
self.assertIn('ATP_ABGANG', codes)
self.assertIn('ATP_ABRECHNUNG_VORSCHUSS', codes)
self.assertAlmostEqual(
self._total(slip, 'URL_ERSATZ_LFD'), 1320.0, places=2)
self.assertAlmostEqual(self._total(slip, 'ABGANG'), 9000.0,
places=2)