[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:
2026-09-13 02:33:26 +02:00
parent c3b9d6eb8e
commit 018554b215
15 changed files with 1556 additions and 3 deletions
@@ -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)))