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: Lohnpfändung — Existenzminimum § 291a EO, DN-Abzug, Kostenersatz, DSE (AP11)
Pfändungs-/Exekutionsmaschinerie nach EO §§ 291/291a/292/301/302/ 292h für den Privatwirtschafts-DN (Plan: personalverrechnung/PLAN-Restluecken-AP11.md §AP11, MVP + Kostenersatz): - Neues Modell l10n.at.payroll.pfaendung (models/pfaendung.py): Gläubiger, Vollstreckungsträger, Pfändungsart, Zustellungsdatum, Forderungshöhe, Status aktiv/gesetzt/geloest, Verwalter (§ 301 Abs 1 Z 6), 4-Wochen-Zahlungssperre (§ 304), Kostenersatz-Variante (§ 292h), DSE-/Auszahlungsfristen (computed), Rang nach Einlangedatum, Constraint Löse-/Zustellungsdatum; hr.version: l10n_at_unterhaltspflichten (0–5) und l10n_at_sz_anspruch (§ 291a Abs 2 Z 1/Z 2); hr.salary.rule: Flags l10n_at_pfandung_unpfandbar (AP12-Schnittstelle) und l10n_at_pfandung_beendigung (AP14/15-Schnittstelle) - Engine (hr.payslip): Existenzminimum-Maschine § 291a EO je Topf (laufend/sz13/sz14/beendigung), Pfändungsberechnungsgrundlage § 291 EO (laufend = Bezüge − unpfändbar − SV-DN − LSt), Masse, Kostenersatz § 292h (2 %/max 8 € + 1 %/max 4 €, nie vom Existenzminimum), Regeln PFAENDUNG (seq 75) / PFAENDUNG_SZ (seq 76); result_rules enthält ungerundete Rule-Summen — Lesezugänge runden auf Cents (Zeilenbeträge), sonst 1-Cent-Drift - Parameter at_eo_azr (2025: 1.273,99 / 2026: 1.308,39 €) und at_eo_kostenersatz; DSE-Report (qweb-pdf auf hr.employee), Views/Menu unter Payroll-Wurzel, Security (payroll-Groups + Company-ir.rule) - Rechenfälle docs/rechenfaelle/pfaendung.md (RF-PFA-1…7): lb-pfa-03 exakt verifiziert (33.840 € → EX 16.684 € / pfändbar 17.156 €); 103/103 Tests grün - Offene Reste dokumentiert: Unterhaltsexekution 75 %/Koinzidenz, Vorschüsse § 290c, Sachbezüge (AP12), Beendigungs-Regeln (AP14/15), 2027-AZR-Werte Version 19.0.9.5.0
This commit is contained in:
@@ -12,3 +12,4 @@ from . import test_einstufung_private
|
||||
from . import test_efzg_private
|
||||
from . import test_mutterschutz_private
|
||||
from . import test_aeb_private
|
||||
from . import test_pfaendung_private
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""AP11: Lohnpfändung — Existenzminimum-Maschine (§ 291a EO) je Topf,
|
||||
DN-Abzug (PFAENDUNG), Kostenersatz (§ 292h) und Rang.
|
||||
Rechenfälle: docs/rechenfaelle/pfaendung.md."""
|
||||
import datetime
|
||||
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class PfaendungPrivateTest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.company = self.env.company
|
||||
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 PFA 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
|
||||
|
||||
def _employee(self, eintritt, unterhalt=0, sz_anspruch=True):
|
||||
employee = self.env['hr.employee'].create({'name': 'PFA Test'})
|
||||
version = self.env['hr.version'].search(
|
||||
[('employee_id', '=', employee.id)], limit=1)
|
||||
version.write({
|
||||
'contract_date_start': eintritt,
|
||||
'kv_id': self.kv2203.id,
|
||||
'kv_gruppe_id': self.gruppe_ii.id,
|
||||
'erfahrungsstufe': 1,
|
||||
'resource_calendar_id': self.calendar.id,
|
||||
'l10n_at_unterhaltspflichten': unterhalt,
|
||||
'l10n_at_sz_anspruch': sz_anspruch,
|
||||
})
|
||||
version.generate_work_entries(
|
||||
datetime.date(2026, 1, 1), datetime.date(2026, 12, 31))
|
||||
return employee
|
||||
|
||||
def _slip(self, employee, monat, berechnen=False):
|
||||
erster = datetime.date(2026, 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': 'PFA %d/2026' % monat,
|
||||
'employee_id': employee.id,
|
||||
'struct_id': self.structure.id,
|
||||
'date_from': erster,
|
||||
'date_to': letzter,
|
||||
})
|
||||
if berechnen:
|
||||
slip.compute_sheet()
|
||||
slip.action_payslip_done()
|
||||
return slip
|
||||
|
||||
def _pfaendung(self, employee, zustellung, glaubiger='Gläubiger X',
|
||||
art='gewoehnlich'):
|
||||
return self.env['l10n.at.payroll.pfaendung'].create({
|
||||
'employee_id': employee.id,
|
||||
'glaubiger': glaubiger,
|
||||
'zustellungsdatum': zustellung,
|
||||
'pfandung_art': art,
|
||||
'status': 'aktiv',
|
||||
})
|
||||
|
||||
def _total(self, slip, code):
|
||||
return sum(slip.line_ids.filtered(
|
||||
lambda l: l.code == code).mapped('total'))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Existenzminimum-Maschine (§ 291a EO) — Rechenfälle RF-PFA-1…4
|
||||
# ------------------------------------------------------------------
|
||||
def test_01_laufend_n0(self):
|
||||
"""RF-PFA-1: laufend, n=0, SZ-Anspruch, basis 2.000 € →
|
||||
EX 1.515,60 / pfändbar 484,40."""
|
||||
employee = self._employee(datetime.date(2026, 1, 1), unterhalt=0)
|
||||
slip = self._slip(employee, 3)
|
||||
ex = slip._at_pfaendung_existenzminimum(2000.0, 0, False)
|
||||
self.assertAlmostEqual(ex, 1515.60, places=2)
|
||||
|
||||
def test_02_laufend_n2(self):
|
||||
"""RF-PFA-1b: n=2, basis 2.000 € → EX 1.915,00 / pfändbar 85,00."""
|
||||
employee = self._employee(datetime.date(2026, 1, 1), unterhalt=2)
|
||||
slip = self._slip(employee, 3)
|
||||
ex = slip._at_pfaendung_existenzminimum(2000.0, 2, False)
|
||||
self.assertAlmostEqual(ex, 1915.00, places=2)
|
||||
|
||||
def test_03_hoechst_cap(self):
|
||||
"""RF-PFA-2: basis 6.000 € > HöBG 5.220 € → EX gedeckelt
|
||||
2.481,60 / pfändbar 3.518,40."""
|
||||
employee = self._employee(datetime.date(2026, 1, 1))
|
||||
slip = self._slip(employee, 3)
|
||||
ex = slip._at_pfaendung_existenzminimum(6000.0, 0, False)
|
||||
self.assertAlmostEqual(ex, 2481.60, places=2)
|
||||
|
||||
def test_04_beendigung_lb_pfa_03(self):
|
||||
"""RF-PFA-3 (lb-pfa-03, exakt): Abfertigung 6×6.000, 6 % LSt →
|
||||
basis 33.840, n=2, erhöht, 6 Bezugsmonate → EX 16.684 /
|
||||
pfändbar 17.156."""
|
||||
employee = self._employee(datetime.date(2026, 1, 1), unterhalt=2)
|
||||
slip = self._slip(employee, 3)
|
||||
ex = slip._at_pfaendung_existenzminimum(
|
||||
33840.0, 2, True, bezugsmonate=6)
|
||||
self.assertAlmostEqual(ex, 16684.00, places=2)
|
||||
self.assertAlmostEqual(33840.0 - ex, 17156.00, places=2)
|
||||
|
||||
def test_05_sz_anspruch_vs_erhoht(self):
|
||||
"""RF-PFA-4: ohne SZ-Anspruch (erhöht) höherer EX bei gleicher
|
||||
basis — EX 1.668,20 / pfändbar 331,80."""
|
||||
employee = self._employee(
|
||||
datetime.date(2026, 1, 1), sz_anspruch=False)
|
||||
slip = self._slip(employee, 3)
|
||||
ex = slip._at_pfaendung_existenzminimum(2000.0, 0, True)
|
||||
self.assertAlmostEqual(ex, 1668.20, places=2)
|
||||
self.assertAlmostEqual(2000.0 - ex, 331.80, places=2)
|
||||
|
||||
def test_06_abs_minimum(self):
|
||||
"""RF-PFA-5: absolutes Geldexistenzminimum 2026 = 654 €
|
||||
(½ × AZR 1.308,39, § 292 Abs 4 EO)."""
|
||||
employee = self._employee(datetime.date(2026, 1, 1))
|
||||
slip = self._slip(employee, 3)
|
||||
w = slip._at_pfaendung_werte()
|
||||
self.assertEqual(w['allg'], 1308)
|
||||
self.assertEqual(w['erh'], 1526)
|
||||
self.assertEqual(w['unterhalt'], 261)
|
||||
self.assertEqual(w['hoechst_j'], 5220)
|
||||
self.assertEqual(w['abs_minimum'], 654)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Kostenersatz (§ 292h EO) — RF-PFA-6
|
||||
# ------------------------------------------------------------------
|
||||
def test_07_kostenersatz(self):
|
||||
"""RF-PFA-6: Masse 1.000 € → erste 2 %/max 8 € + weitere
|
||||
1 %/max 4 € = 12,00 €; Variante 'verzicht' → 0."""
|
||||
employee = self._employee(datetime.date(2026, 1, 1))
|
||||
slip = self._slip(employee, 3)
|
||||
self._pfaendung(
|
||||
employee, datetime.date(2026, 2, 1))
|
||||
# Kostenersatz rechnerisch auf Masse 1.000 € (Parameter-Basis).
|
||||
p = slip._l10n_at_param('at_eo_kostenersatz')
|
||||
kosten = min(1000.0 * p['erste_rate'] / 100.0, p['erste_max'])
|
||||
kosten += min((1000.0 - kosten) * p['weitere_rate'] / 100.0,
|
||||
p['weitere_max'])
|
||||
self.assertAlmostEqual(kosten, 12.00, places=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DN-Abzug im Lauf (PFAENDUNG-Regel) — RF-PFA-7
|
||||
# ------------------------------------------------------------------
|
||||
def test_08_pfaendung_regel_keine_pfandung(self):
|
||||
"""RF-PFA-7a: ohne aktive Pfändung keine PFAENDUNG-Zeile."""
|
||||
employee = self._employee(datetime.date(2026, 1, 1))
|
||||
slip = self._slip(employee, 5, berechnen=True)
|
||||
self.assertFalse(self._total(slip, 'PFAENDUNG'))
|
||||
|
||||
def test_09_pfaendung_regel_laufend(self):
|
||||
"""RF-PFA-7b: PFAENDUNG-Zeile = basis(laufend) − EX; Netto wird
|
||||
reduziert."""
|
||||
employee = self._employee(datetime.date(2026, 1, 1), unterhalt=0)
|
||||
self._pfaendung(
|
||||
employee, datetime.date(2026, 4, 1))
|
||||
slip = self._slip(employee, 5, berechnen=True)
|
||||
bruto = slip._at_pfa_bruto(None)
|
||||
svdn = sum(slip._at_pfa_rule_amount(
|
||||
None, c) for c in (
|
||||
'SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK', 'SVDN_WF'))
|
||||
lst = slip._at_pfa_rule_amount(None, 'LST_LFD')
|
||||
basis = max(0.0, bruto - svdn - lst)
|
||||
ex = slip._at_pfaendung_existenzminimum(
|
||||
basis, 0, not employee._at_sz_anspruch())
|
||||
pfaendbar = round(max(0.0, basis - ex), 2)
|
||||
self.assertGreater(basis, 0.0)
|
||||
self.assertGreater(pfaendbar, 0.0)
|
||||
self.assertAlmostEqual(self._total(slip, 'PFAENDUNG'),
|
||||
pfaendbar, places=2)
|
||||
|
||||
def test_10_rang(self):
|
||||
"""Zwei Pfändungen → Rang 1 (früheres Einlangen) und Rang 2."""
|
||||
employee = self._employee(datetime.date(2026, 1, 1))
|
||||
p1 = self._pfaendung(
|
||||
employee, datetime.date(2026, 3, 1), glaubiger='Früh')
|
||||
p2 = self._pfaendung(
|
||||
employee, datetime.date(2026, 4, 1), glaubiger='Spät')
|
||||
self.assertEqual(p1.rang, 1)
|
||||
self.assertEqual(p2.rang, 2)
|
||||
aktiv = employee._at_pfaendungen_aktiv(datetime.date(2026, 5, 31))
|
||||
self.assertEqual(list(aktiv.ids), [p1.id, p2.id])
|
||||
|
||||
def test_11_status_geloest(self):
|
||||
"""Gelöste Pfändung zählt nicht mehr als aktiv (lb-pfa-08)."""
|
||||
employee = self._employee(datetime.date(2026, 1, 1))
|
||||
p1 = self._pfaendung(
|
||||
employee, datetime.date(2026, 3, 1))
|
||||
p1.action_geloest()
|
||||
self.assertEqual(p1.status, 'geloest')
|
||||
self.assertFalse(
|
||||
employee._at_pfaendungen_aktiv(datetime.date(2026, 5, 31)))
|
||||
Reference in New Issue
Block a user