mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 08:52:45 +00:00
[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:
@@ -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 + (7−N) 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)
|
||||
Reference in New Issue
Block a user