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 unpaid care/parental leave family (AP16)
Close the parental/care-leave block after the pilot-client scope release (8.13): six unpaid full-release kinds join the generic parental-leave work entry — Bildungskarenz (AVRAG § 11, two to twelve months by agreement with the AMS-financed Weiterbildungsbeihilfe), Familienhospizkarenz for terminal accompaniment (AVRAG § 14a, three to six months), accompaniment of seriously ill children (§ 14b, five to nine months), children's rehabilitation leave (§ 14e, four weeks per year), Pflegekarenz (§ 14c, one to three months, undivided) and the Papa-Monat (VKG § 1a, one month, creditable toward seniority under § 1a Abs 7 VKG iVm § 15f MSchG while Bildungs-/Pflegekarenz is not). All are unpaid at the employer — the external bodies pay the replacement income (AK, AMS, Bundesamt, KV-Träger) — so the new negative KARENZ_ENTGELT rule stops the employer wage per working day through the shared Tagesatz helper whose exclusion list now covers EFZG, Mutterschutz and Karenz corrections. The Versichertenmeldung run emits M4 deregistrations at the end of the pay entitlement (the day before the release begins) and M3 re-registrations at the return date — omitted when the employment itself ends — with the eSV Karenzart strings logged as protocol information pending the special DM-ORG formats (GP7 track). The fictive BMSVG basis gains the § 7 Abs 4 Z 1–3 bridge: when a new maternity prohibition follows a parental-leave stretch directly or after employment of less than three months, the reference months fall before the preceding prohibition, and a re-founded employment within three months of the ended karenziert employment uses the single last full calendar month; contributions during the KBG period are FLAF-financed (§ 7 Abs 5) and during hospice/care leave Bund-financed (§ 7 Abs 6) — no employer rule, verified by test. Pflegeteilzeit and comparable reduced-hour arrangements pay the BV contribution on the pre-reduction wage via a new version field consumed by the BMSVG basis (§ 6 Abs 4). The endabrechnung wizard gains the § 23 Abs 8 AngG full-time re-rating preview (monthly salary ÷ work_time_rate, confirmable) with § 23a Abs 4a and UEL § 10 Abs 4 UrlG guidance. The Mutterschutz status now detects the § 163 ASVG Sonderwochengeld case (prohibition inside a Karenz — the KV-Träger pays the increased sickness benefit) and warns on § 166 ASVG suspension (full for >50 % continued pay, half at 50 %, activity and Pflegekarenzgeld hints). Verified against the original statute text in .ris/ (AVRAG §§ 11/11a/14a–14e, VKG § 1a, MSchG § 15f, BMSVG § 7 Abs 4–6, ASVG §§ 163/166) and the knowledge-base sources lb-kar/elt/kzs/pap/kbg; the vacation proration of leave stretches (§ 15f Abs 2 MSchG on the hr_holidays allocation side), the eSV special formats for the Karenz art block, the full Sonderwochengeld computation and the leave-overlap policy decision remain documented open points. Version 19.0.17.0.0 (private)
This commit is contained in:
@@ -18,3 +18,4 @@ from . import test_sachbezuege2_private
|
||||
from . import test_reisekosten_private
|
||||
from . import test_pendler_private
|
||||
from . import test_endabrechnung_private
|
||||
from . import test_karenz_private
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""AP16 (8.13): Karenz-/Familien-Freistellungen — Entgelt-Stopp je
|
||||
unbezahlter Karenz-Art (KARENZ_ENTGELT, Muster MSCH_ENTGELT), eSV-An-/
|
||||
Abmeldungen aus der vr.event-Registry (Papa-Monat tagesgenau, Karenz-
|
||||
Art-Protokoll), fiktive BV mit §-7-Abs-4-Z-1–3-BMSVG-Karenz-Brücke,
|
||||
BV-Vor-Entgelt bei Pflegeteilzeit (§ 6 Abs 4 BMSVG), Sonderwochengeld
|
||||
(§ 163 ASVG) und Ruhen-Warnungen (§ 166 ASVG), §-23-Abs-8-ETZ-
|
||||
Hochrechnung im Endabrechnungs-Wizard."""
|
||||
import base64
|
||||
import datetime
|
||||
|
||||
from odoo import SUPERUSER_ID
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class KarenzPrivateTest(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 Karenz 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.kalender_teilzeit = self.env['resource.calendar'].create({
|
||||
'name': 'AT Karenz 20h (Teilzeit)',
|
||||
'tz': 'Europe/Vienna',
|
||||
'hours_per_day': 4,
|
||||
'attendance_ids': [
|
||||
(0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd),
|
||||
'hour_from': 8.0, 'hour_to': 12.0})
|
||||
for wd in range(5)
|
||||
],
|
||||
})
|
||||
self.company.resource_calendar_id = self.calendar
|
||||
self.typ_bildung = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.leave_type_at_bildungskarenz')
|
||||
self.typ_pflege = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.leave_type_at_pflegekarenz')
|
||||
self.typ_karenz = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.leave_type_at_karenz')
|
||||
self.typ_msch = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.leave_type_at_mutterschutz')
|
||||
self.typ_krank = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.leave_type_at_krankenstand')
|
||||
|
||||
def _employee(self, teilzeit=False, ssnid=False, ende=None):
|
||||
employee = self.env['hr.employee'].create({
|
||||
'name': 'Karenz Test', 'sex': 'female',
|
||||
'birthday': '1990-05-15'})
|
||||
version = self.env['hr.version'].search(
|
||||
[('employee_id', '=', employee.id)], limit=1)
|
||||
version.write({
|
||||
'contract_date_start': datetime.date(2020, 7, 1),
|
||||
'kv_id': self.kv2203.id,
|
||||
'kv_gruppe_id': self.gruppe_ii.id,
|
||||
'erfahrungsstufe': 1,
|
||||
'ueberzahlung': 2059.41,
|
||||
'resource_calendar_id': self.calendar.id,
|
||||
})
|
||||
if ssnid:
|
||||
version.write({'ssnid': '1234150590',
|
||||
'sex': 'female'})
|
||||
if ende:
|
||||
version.write({'contract_date_end': ende})
|
||||
version.generate_work_entries(
|
||||
datetime.date(2026, 8, 1), datetime.date(2027, 3, 31))
|
||||
return employee
|
||||
|
||||
def _slip(self, employee, monat, jahr=2026, confirm=False):
|
||||
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': 'Karenz %d/%d' % (monat, jahr),
|
||||
'employee_id': 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 _total(self, slip, code):
|
||||
return sum(slip.line_ids.filtered(
|
||||
lambda l: l.code == code).mapped('total'))
|
||||
|
||||
def _leave(self, employee, typ, von, bis, skip_check=False):
|
||||
vals = {
|
||||
'name': '%s %s' % (typ.name, von),
|
||||
'employee_id': employee.id,
|
||||
'holiday_status_id': typ.id,
|
||||
'request_date_from': von,
|
||||
'request_date_to': bis,
|
||||
}
|
||||
if skip_check:
|
||||
# Überlappende Freistellungen (Krankheit während Karenz/MSchG):
|
||||
# direkt auf state='validate' — _action_validate würde an
|
||||
# number_of_days == 0 (vom Karenz-Typ belegte Tage) scheitern.
|
||||
leave = self.env['hr.leave'].with_context(
|
||||
leave_skip_date_check=True,
|
||||
leave_skip_state_check=True).create(
|
||||
dict(vals, state='validate'))
|
||||
return leave
|
||||
leave = self.env['hr.leave'].create(vals)
|
||||
leave.with_user(SUPERUSER_ID)._action_validate()
|
||||
return leave
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RF-KAR-1: Entgelt-Stopp je Karenz-Art (KARENZ_ENTGELT)
|
||||
# ------------------------------------------------------------------
|
||||
def test_01_entgelt_stopp_bildungskarenz(self):
|
||||
"""Bildungskarenz 1.-15. 11. (§ 11 AVRAG, 15 KT/10 AT): das
|
||||
Karenzgeld zahlt die AK — KARENZ_ENTGELT stoppt das
|
||||
Dienstgeber-Entgelt je Arbeitstag (Muster MSCH_ENTGELT)."""
|
||||
emp_mit = self._employee()
|
||||
emp_ohne = self._employee()
|
||||
slip_mit = self._slip(emp_mit, 11)
|
||||
slip_ohne = self._slip(emp_ohne, 11)
|
||||
self._leave(emp_mit, self.typ_bildung,
|
||||
datetime.date(2026, 11, 1),
|
||||
datetime.date(2026, 11, 15))
|
||||
slip_mit.compute_sheet()
|
||||
|
||||
status = slip_mit._at_karenz_status()
|
||||
self.assertEqual(len(status['kalendertage']), 15)
|
||||
self.assertEqual(len(status['arbeitstage']), 10)
|
||||
self.assertTrue(any('Bildungskarenz' in a
|
||||
for a in status['arten']))
|
||||
tagesatz = 2059.41 / 21
|
||||
self.assertAlmostEqual(self._total(slip_mit, 'KARENZ_ENTGELT'),
|
||||
-tagesatz * 10, places=2)
|
||||
# Keine MSCH-Wirkung; SV-Basis durch die negative Zeile reduziert
|
||||
self.assertEqual(self._total(slip_mit, 'MSCH_ENTGELT'), 0.0)
|
||||
basis_diff = (slip_ohne._l10n_at_sv_basis_roh()
|
||||
- slip_mit._l10n_at_sv_basis_roh())
|
||||
self.assertAlmostEqual(basis_diff, tagesatz * 10, places=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RF-KAR-2: eSV-An-/Abmeldungen aus Karenz (vr.event)
|
||||
# ------------------------------------------------------------------
|
||||
def test_02_vr_events_karenz(self):
|
||||
"""Pflegekarenz 1.-15. 11.: Abmeldung zum Tag vor Beginn
|
||||
(Ende des Entgeltanspruchs, § 163/§ 11 ASVG-Rahmen) und
|
||||
Anmeldung zur Rückkehr (16. 11.); Karenzart im Protokoll;
|
||||
erneuter Lauf idempotent (Registry-Dedup)."""
|
||||
self.company.write({
|
||||
'l10n_at_beitragskontonummer': '1412345678',
|
||||
})
|
||||
employee = self._employee(ssnid=True)
|
||||
self._leave(employee, self.typ_pflege,
|
||||
datetime.date(2026, 11, 1),
|
||||
datetime.date(2026, 11, 15))
|
||||
lauf = self.env['l10n.at.payroll.vr'].create({
|
||||
'company_id': self.company.id,
|
||||
'stichtag': datetime.date(2026, 12, 31),
|
||||
})
|
||||
lauf.action_erzeugen()
|
||||
self.assertEqual(lauf.state, 'done')
|
||||
events = self.env['l10n.at.payroll.vr.event'].search([
|
||||
('company_id', '=', self.company.id),
|
||||
('employee_id', '=', employee.id),
|
||||
])
|
||||
adate = {(e.art, e.adatum) for e in events}
|
||||
self.assertIn(('M4', datetime.date(2026, 10, 31)), adate)
|
||||
self.assertIn(('M3', datetime.date(2026, 11, 16)), adate)
|
||||
self.assertIn('Pflegekarenz', lauf.protokoll)
|
||||
roh = base64.b64decode(lauf.datei).decode('ISO-8859-15')
|
||||
self.assertIn('M4-K', roh)
|
||||
self.assertIn('M3-K', roh)
|
||||
# Idempotenz: zweite Meldungen sind registriert → Lauf leer
|
||||
lauf2 = self.env['l10n.at.payroll.vr'].create({
|
||||
'company_id': self.company.id,
|
||||
'stichtag': datetime.date(2026, 12, 31),
|
||||
})
|
||||
with self.assertRaises(UserError):
|
||||
lauf2.action_erzeugen()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RF-KAR-3: BMSVG-Karenz-Brücke (§ 7 Abs 4 Z 1–3)
|
||||
# ------------------------------------------------------------------
|
||||
def test_03_bmsvg_karenz_bruecke(self):
|
||||
"""Mutterschutz-Frist Nov 2026, Karenz Dez 2026–Feb 2027,
|
||||
neuerliches Verbot 1. 3. 2027 (Z 1): der Bemessungsstichtag der
|
||||
fiktiven BV ist das der Karenz unmittelbar vorangegangene
|
||||
Beschäftigungsverbot — Referenzmonate Aug/Sep/Okt 2026."""
|
||||
employee = self._employee()
|
||||
self._leave(employee, self.typ_msch,
|
||||
datetime.date(2026, 11, 1),
|
||||
datetime.date(2026, 11, 15))
|
||||
self._leave(employee, self.typ_karenz,
|
||||
datetime.date(2026, 11, 16),
|
||||
datetime.date(2027, 2, 28))
|
||||
self._leave(employee, self.typ_msch,
|
||||
datetime.date(2027, 3, 1),
|
||||
datetime.date(2027, 3, 15))
|
||||
slip = self._slip(employee, 11, jahr=2026)
|
||||
stichtag, anzahl = slip._at_msch_referenz_stichtag_und_anzahl(
|
||||
datetime.date(2027, 3, 1))
|
||||
self.assertEqual(stichtag, datetime.date(2026, 11, 1))
|
||||
self.assertEqual(anzahl, 3)
|
||||
monate = slip._at_msch_referenz_monate(datetime.date(2027, 3, 1))
|
||||
self.assertEqual(
|
||||
monate,
|
||||
[datetime.date(2026, 8, 1), datetime.date(2026, 9, 1),
|
||||
datetime.date(2026, 10, 1)])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RF-KAR-4: BV-Vor-Entgelt bei Pflegeteilzeit (§ 6 Abs 4 BMSVG)
|
||||
# ------------------------------------------------------------------
|
||||
def test_04_bv_vor_entgelt_pflegeteilzeit(self):
|
||||
"""20-h-Teilzeit (work_time_rate 0,5) mit gesetztem
|
||||
Vor-Reduzierungs-Entgelt: die BV-Basis läuft auf dem
|
||||
Vor-Reduzierungs-Entgelt (§ 6 Abs 4 BMSVG), nicht auf der
|
||||
reduzierten laufenden Basis."""
|
||||
employee = self._employee()
|
||||
version = employee.version_id
|
||||
version.write({
|
||||
'l10n_at_abfertigung_neu': True,
|
||||
'l10n_at_bv_vor_entgelt': 2059.41,
|
||||
})
|
||||
version.resource_calendar_id = self.kalender_teilzeit
|
||||
version.generate_work_entries(
|
||||
datetime.date(2026, 8, 1), datetime.date(2026, 11, 30))
|
||||
slip = self._slip(employee, 11)
|
||||
self.assertAlmostEqual(slip._at_bv_basis_laufen(), 2059.41,
|
||||
places=2)
|
||||
# OHNE §-6-Abs-4-Feld: reduzierte laufende Basis
|
||||
version.write({'l10n_at_bv_vor_entgelt': 0.0})
|
||||
slip.compute_sheet()
|
||||
self.assertAlmostEqual(slip._at_bv_basis_laufen(), 1029.71,
|
||||
places=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RF-KAR-5: Sonderwochengeld (§ 163) und Ruhen (§ 166)
|
||||
# ------------------------------------------------------------------
|
||||
def test_05_sonderwochengeld_und_ruhen(self):
|
||||
"""Mutterschutz-Frist 1.-15. 11. innerhalb einer Pflegekarenz:
|
||||
Sonderwochengeld statt Wochengeld (§ 163 ASVG); Krankenstand
|
||||
1.-10. 11. (Entgeltfortzahlung 100 %) → Ruhen-Warnung
|
||||
(§ 166 Abs 1 Z 2)."""
|
||||
employee = self._employee()
|
||||
# Karenz + Krankenstand + Mutterschutz überlappen real (Krankheit
|
||||
# während der Frist); Odos Quere-Overlap-Sperre wird im Test mit
|
||||
# dem offiziellen leave_skip_date_check-Bypass umgangen — die
|
||||
# Leave-Überlappungs-Policy ist Mandanten-Entscheid (offener
|
||||
# Punkt in docs/rechenfaelle/karenz.md).
|
||||
self._leave(employee, self.typ_pflege,
|
||||
datetime.date(2026, 11, 1),
|
||||
datetime.date(2026, 12, 15), skip_check=True)
|
||||
self._leave(employee, self.typ_krank,
|
||||
datetime.date(2026, 11, 1),
|
||||
datetime.date(2026, 11, 10), skip_check=True)
|
||||
self._leave(employee, self.typ_msch,
|
||||
datetime.date(2026, 11, 5),
|
||||
datetime.date(2026, 11, 15), skip_check=True)
|
||||
# Krankenstand Nov 1–10 als direkte Work Entries (ein Leave auf
|
||||
# denselben Tagen erzeugt der Work-Entry-Generator nicht — die
|
||||
# Karenz belegt die Tage bereits); die EFZ-Maschine liest die
|
||||
# KRANK-Entries für die Fortzahlungs-Phase.
|
||||
krank_typ = self.env['hr.work.entry.type'].search(
|
||||
[('code', '=', 'ATP_KRANK')], limit=1)
|
||||
arbeitstage = [datetime.date(2026, 11, d) for d in
|
||||
(2, 3, 4, 5, 6, 9, 10)]
|
||||
for tag in arbeitstage:
|
||||
self.env['hr.work.entry'].create({
|
||||
'name': 'Krankenstand %s' % tag,
|
||||
'employee_id': employee.id,
|
||||
'work_entry_type_id': krank_typ.id,
|
||||
'date': tag,
|
||||
'duration': 8.0,
|
||||
})
|
||||
slip = self._slip(employee, 11)
|
||||
slip.compute_sheet()
|
||||
self.assertTrue(slip._at_msch_sonderwochengeld())
|
||||
warnungen = slip._at_msch_ruhen_warnungen()
|
||||
self.assertTrue(any('Ruhen zur Gänze' in w for w in warnungen))
|
||||
self.assertIn('Sonderwochengeld', slip.l10n_at_msch_info)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RF-KAR-6: §-23-Abs-8-Hochrechnung im Wizard (Elternteilzeit)
|
||||
# ------------------------------------------------------------------
|
||||
def test_06_etz_hochrechnung_wizard(self):
|
||||
"""20-h-Versionskalender (work_time_rate 0,5): der §-23-Abs-8-
|
||||
Hochrechnungs-Vorschlag = Monatsentgelt ÷ work_time_rate; die
|
||||
Übernahme bleibt bestätigt (kein Auto-Write)."""
|
||||
employee = self._employee()
|
||||
version = employee.version_id
|
||||
version.resource_calendar_id = self.kalender_teilzeit
|
||||
version.generate_work_entries(
|
||||
datetime.date(2026, 8, 1), datetime.date(2026, 11, 30))
|
||||
wizard = self.env['l10n.at.payroll.endabrechnung.wizard'].create({
|
||||
'employee_id': employee.id,
|
||||
'austrittsdatum': datetime.date(2026, 11, 15),
|
||||
'departure_reason_id': self.env.ref(
|
||||
'l10n_at_hr_payroll'
|
||||
'.departure_reason_einvernehmlich').id,
|
||||
'abf_alt_aktiv': True,
|
||||
'abf_alt_monatsentgelt': 1029.71,
|
||||
'abf_alt_laufender_arbeitslohn': 1029.71,
|
||||
'abf_alt_monatslohnsteuer': 0.0,
|
||||
'abf_alt_mehrfach': 2.0,
|
||||
'abf_alt_anspruchsart': 'normal',
|
||||
})
|
||||
self.assertAlmostEqual(wizard.abf_alt_hochrechnung_vorschau,
|
||||
2059.42, places=2)
|
||||
Reference in New Issue
Block a user