diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md
index 0532c22..ede2906 100644
--- a/.agents/MEMORY.md
+++ b/.agents/MEMORY.md
@@ -2,6 +2,45 @@
## Current focus
+**AP16 Karenz-/Familien-Reste umgesetzt (8.13 freigegeben) — Kern 73/73, Privat 199/199 Tests grün**
+(2026-09-15, uncommittet bis Nutzerfreigabe; Privat **19.0.17.0.0**):
+
+- **8.13 komplett freigegeben** (Nutzer, 2026-09-15) — Mutterschutz-/Karenz-
+ Block im R1-Scope; §-Verifikation am Originalwortlaut (AVRAG §§ 11/11a/
+ 14a–14e/15a, VKG § 1a, MSchG § 15f, BMSVG § 7 Abs 4–6, ASVG §§ 163/166).
+ KB-Synthesen (3 Subagenten): lb-kar/elt/kzs/pap/kbg — arbeitsrechtliche
+ Kerne + Abrechnungstypologie (alle Voll-Freistellungen entgeltlos,
+ Leistungen extern: AK/AMS/BASB/KV/Gesundheitskasse; elt-SV/LSt/BMSVG-Lücken
+ verifizieren = erledigt über RIS/Bestandsmaschinerie; ⚠ lb-elt-06 §-8/§-8a-
+ VKG-Zuordnung dokumentiert).
+- **Privat (19.0.17.0.0):** 6 neue WETs/Urlaubsarten (ATP_BILDUNGSKARENZ,
+ ATP_HOSPIZKARENZ, ATP_KINDERKRANK, ATP_KINDERREHAB, ATP_PFLEGEKARENZ,
+ ATP_PAPAMONAT); Regel `KARENZ_ENTGELT` (seq 48, negatives Tagesatz-Muster
+ wie MSCH_ENTGELT, Ausschlüsse im geteilten `_at_efz_tagesatz`); Modell
+ `models/karenz.py` (Status/Tage/Karenzarten-Map); vr.event-An-/Abmeldungen
+ aus Karenz (M4 Tag vor Beginn, M3 Rückkehr, entfällt bei DV-Ende; Karenz-
+ art-Strings im Protokoll, eSV-Sonderformate GP7); fiktive BV mit §-7-Abs-4-
+ **Z-1–3-Brücke** (`_at_msch_referenz_stichtag_und_anzahl` in mutterschutz.py);
+ BV-Vor-Entgelt-Feld `l10n_at_bv_vor_entgelt` (§ 6 Abs 4 BMSVG, Pflegeteilzeit)
+ in `_at_bv_basis_laufen`; §-23-Abs-8-Hochrechnungs-Vorschau im Wizard
+ (`abf_alt_hochrechnung_vorschau`); Sonderwochengeld-Erkennung (§ 163 ASVG)
+ + Ruhen-Warnungen (§ 166 ASVG) im msch_info.
+- **Tests:** neu `tests/test_karenz_private.py` 6 (Entgelt-Stopp, VR-Events
+ inkl. Idempotenz + Protokoll, Z-1-Brücke, §-6-Abs-4-BV, § 163/§ 166,
+ Wizard-Hochrechnung); Suite **214/214** (73 Kern + 199 Privat). Test-Details:
+ hr.leave-Quere-Überlappung nur mit `leave_skip_date_check`+`leave_skip_state_check`
+ + direktem state='validate' umgehbar (Krankheit während Karenz);
+ EFZ-Phasen lesen Leaves, `voll_arbeitstage` Work-Entries — überlappte Tage
+ brauchen direkte hr.work.entry-Records; `hr.leave` hat KEIN
+ work_entry_type_id (nur der Leave-Typ).
+- **Offen AP16-Reste (docs/rechenfaelle/karenz.md):** Urlaubs-Aliquotierung aus
+ Karenz (§ 15f Abs 2 MSchG, hr_holidays-Allokationsseite), Leave-Überlappungs-
+ Policy (allow_request_on_top/time_type — Mandanten-Entscheid), eSV-Sonderformate
+ (Karenzart-Block, GP7), Sonderwochengeld-Vollrechnung (§ 141/§ 125/§ 108f =
+ Anzeige-Stufe), lb-kar-02-13-Wochen-Über-/§ 2i-AVRAG-Konflikt (dokumentiert).
+- **Offen gesamt:** AP17 Meldewesen-Reste (L17/§ 109a/§ 109b, Geringfügigen-
+ Jahreszahlung), AP18+ (Gates). Nicht committen ohne Nutzeranforderung.
+
**AP14-B Prämien bei Beendigung (§ 16 AngG) umgesetzt — Kern 73/73, Privat 191/191 Tests grün**
(2026-09-15, uncommittet bis Nutzerfreigabe; Privat **19.0.16.0.0**):
diff --git a/addons/l10n_at_hr_payroll_private/__manifest__.py b/addons/l10n_at_hr_payroll_private/__manifest__.py
index 2325739..04dbc92 100644
--- a/addons/l10n_at_hr_payroll_private/__manifest__.py
+++ b/addons/l10n_at_hr_payroll_private/__manifest__.py
@@ -1,7 +1,7 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
{
'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)',
- 'version': '19.0.16.0.0',
+ 'version': '19.0.17.0.0',
'author': 'Florian Egger',
'website': 'https://gem360.at',
'category': 'Human Resources/Payroll',
@@ -109,6 +109,7 @@ gem360-Brücke).
'data/salary_rules_lst_private.xml',
'data/salary_rules_efzg_private.xml',
'data/salary_rules_msch_private.xml',
+ 'data/salary_rules_karenz_private.xml',
'data/salary_rules_pfaendung_private.xml',
'data/salary_rules_sb_private.xml',
diff --git a/addons/l10n_at_hr_payroll_private/data/absence_private.xml b/addons/l10n_at_hr_payroll_private/data/absence_private.xml
index e6a33b4..5460624 100644
--- a/addons/l10n_at_hr_payroll_private/data/absence_private.xml
+++ b/addons/l10n_at_hr_payroll_private/data/absence_private.xml
@@ -113,4 +113,117 @@
+
+
+
+ Bildungskarenz (§ 11 AVRAG)
+ 4
+ BLK
+ ATP_BILDUNGSKARENZ
+
+
+
+
+ Familienhospizkarenz — Sterbebegleitung (§ 14a AVRAG)
+ 5
+ HSP
+ ATP_HOSPIZKARENZ
+
+
+
+
+ Begleitung schwerst erkrankter Kinder (§ 14b AVRAG)
+ 7
+ KKB
+ ATP_KINDERKRANK
+
+
+
+
+ Freistellung Kinderrehabilitation (§ 14e AVRAG)
+ 10
+ KRE
+ ATP_KINDERREHAB
+
+
+
+
+ Pflegekarenz (§ 14c AVRAG)
+ 1
+ PFG
+ ATP_PFLEGEKARENZ
+
+
+
+
+ Papa-Monat (§ 1a VKG)
+ 0
+ PAP
+ ATP_PAPAMONAT
+
+
+
+
+ Bildungskarenz (§ 11 AVRAG)
+
+ hr
+ day
+
+
+
+
+
+ Familienhospizkarenz — Sterbebegleitung
+
+ hr
+ day
+
+
+
+
+
+ Begleitung schwerst erkrankter Kinder
+
+ hr
+ day
+
+
+
+
+
+ Freistellung Kinderrehabilitation
+
+ hr
+ day
+
+
+
+
+
+ Pflegekarenz (§ 14c AVRAG)
+
+ hr
+ day
+
+
+
+
+
+ Papa-Monat (§ 1a VKG)
+
+ hr
+ day
+
+
+
+
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll_private/data/salary_rules_karenz_private.xml b/addons/l10n_at_hr_payroll_private/data/salary_rules_karenz_private.xml
new file mode 100644
index 0000000..6eff835
--- /dev/null
+++ b/addons/l10n_at_hr_payroll_private/data/salary_rules_karenz_private.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Karenz-Freistellung (kein Dienstgeber-Entgelt; Leistungen extern)
+ KARENZ_ENTGELT
+
+
+
+ python
+ 0
+ ]]>
+ code
+ laufend
+
+
+
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/karenz.md b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/karenz.md
new file mode 100644
index 0000000..0648a7b
--- /dev/null
+++ b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/karenz.md
@@ -0,0 +1,113 @@
+# Karenz-/Familien-Freistellungen — Rechenfälle AP16 (8.13 freigegeben)
+
+Karenz-Familie im Privatmodul: unbezahlte Voll-Freistellungen (Eltern-
+karenz generisch, Bildungs-, Familienhospiz-/Sterbebegleitungs-,
+Kinderkranken-Begleitungs-, Kinderrehabilitations- und Pflegekarenz,
+Papa-Monat) mit Entgelt-Stopp, eSV-An-/Abmeldungen, fiktiver BV mit
+§-7-Abs-4-Brücke, §-6-Abs-4-BV bei Teilzeit, Sonderwochengeld (§ 163
+ASVG) und Ruhen (§ 166 ASVG). Grundlagen: KB lb-kar-01…06, lb-elt-
+01…06, lb-kzs-01…07, lb-pap-01/02, lb-kbg-06 (Stand 2026-07) und die
+verifizierten Originaltexte in `.ris/` (AVRAG, VKG, MSchG, KBGG,
+FamZeitbG, BMSVG § 7, ASVG §§ 162/163/166).
+
+## RF-KAR-1 — Entgelt-Stopp je Karenz-Art (test_01)
+
+Bildungskarenz 1.–15. 11. 2026 (§ 11 AVRAG: 2–12 Monate, teilbar ≥ 2,
+Rahmenfrist 4 Jahre, kein Rechtsanspruch — Einvernehmen): das
+Karenzgeld zahlt die **AK** (nicht der AG) — `KARENZ_ENTGELT` stoppt
+das Dienstgeber-Entgelt je Arbeitstag (negatives Tagesatz-Muster von
+`MSCH_ENTGELT`, geteilter Tagesatz-Helfer `_at_efz_tagesatz`, der
+Ausschlüsse mit EFZG/MSCH teilt). 10 AT → −(2.059,41/21) × 10.
+Gleiches Muster für Hospizkarenz (§ 14a, 3/6 Monate), Begleitung
+schwerst erkrankter Kinder (§ 14b, 5/9 Monate), Kinderrehabilitation
+(§ 14e, 4 Wochen/Jahr), Pflegekarenz (§ 14c, 1–3 Monate, unteilbar)
+und Papa-Monat (§ 1a VKG, 1 Monat).
+
+## RF-KAR-2 — eSV-An-/Abmeldungen (vr.event)
+
+- Abmeldung zum **Ende des Entgeltanspruchs** = Tag vor dem Karenz-
+ beginn; Anmeldung zum ersten Arbeitstag nach dem Karenzende —
+ entfällt, wenn das DV mit der Karenz endet (normale Vertrags-
+ Abmeldung).
+- **Papa-Monat/Familienzeit tagesgenau:** Abmeldung am Tag **vor**
+ Beginn der Familienzeit (§ 11 Abs 3 lit a ASVG — unbezahlter Urlaub
+ hält die Pflichtversicherung nur bis 1 Monat, nicht bei Familien-
+ zeit; lb-pap-01), Anmeldung vor Arbeitsantritt.
+- Karenzart-Strings („Familienhospizkarenz/Pflegekarenz An-/Abmeldung",
+ lb-kzs-05) laufen als Protokoll-Info; erweiterte eSV-Sonderformate
+ = GP7-Rest. Idempotenz über die Registry (employee, art, adatum).
+
+## RF-KAR-3 — Fiktive BV mit Karenz-Brücke (§ 7 Abs 4 Z 1–3 BMSVG)
+
+Mutterschutz-Frist 1.–15. 11. 2026 (Verbot 1) → Elternkarenz
+16. 11. 2026–28. 2. 2027 → neuerliches Beschäftigungsverbot 1. 3. 2027
+(Z 1, unmittelbar nach der Karenz): die fiktive BV-Basis des
+neuerlichen Verbots bemisst sich nach dem **Monatsentgelt des
+Kalendermonats vor dem Verbot 1** — Referenzmonate Aug/Sep/Okt 2026
+(statt Dez 2026–Feb 2027, die karenzbedingt leer wären). Z 2
+(Beschäftigung < 3 Monate zwischen Karenz und Verbot): gleicher
+Stichtag. Z 3 (neues DV binnen 3 Monaten nach Beendigung des
+karenzierten DV mit Karenz): der letzte volle Kalendermonat vor dem
+neuerlichen Verbot als EINZELNER Referenzmonat („volles Monatsentgelt").
+
+## RF-KAR-4 — BV-Vor-Entgelt bei Pflegeteilzeit (§ 6 Abs 4 BMSVG)
+
+20-h-Teilzeit (work_time_rate 0,5) mit Vor-Reduzierungs-Entgelt
+2.059,41 €: die BV-Basis läuft während der Maßnahme auf dem
+Vor-Reduzierungs-Entgelt (§ 6 Abs 4 BMSVG), nicht auf der reduzierten
+laufenden Basis (1.029,71 €). Bildungs-/Hospizteilzeit analog über
+das Feld `l10n_at_bv_vor_entgelt` am Vertrag.
+
+## RF-KAR-5 — Sonderwochengeld (§ 163 ASVG) und Ruhen (§ 166 ASVG)
+
+- Mutterschutz-Frist **innerhalb** einer Karenz: kein Wochengeld
+ (§ 162), sondern **Sonderwochengeld** — Zahlung durch die ÖGK
+ (erhöhtes Krankengeld § 141 Abs 2, Bemessungsgrundlage § 125 aus
+ dem Arbeitsverdienst vor dem Ende des letzten Entgeltanspruchs,
+ §-108f-Anpassung); Ausweis im Mutterschutz-Status.
+- Ruhen (§ 166 Abs 1): Fortbezug > 50 % (Entgeltfortzahlung 100 %-
+ Phase) → Ruhen zur Gänze, = 50 % (Halbphase) → hälftig; Zeiten der
+ Urlaubsersatzleistung/UEL/KE gelten nicht als weitergeleistete
+ Bezüge; Z 3 Erwerbstätigkeit (in Höhe des Einkommens) und Z 4
+ Pflegekarenzgeld (in dessen Höhe) = Bezugszeitraum-Daten der ÖGK
+ (Hinweis).
+
+## RF-KAR-6 — Elternteilzeit: Entgelt und Beendigungs-Basen
+
+- Entgelt: letzter voller Lohn/Gehalt, aliquotiert nach der
+ herabgesetzten Wochenstundenzahl (lb-elt-02) — maschinell über
+ `work_time_rate` der Version (Versionskalender ÷ Firmenzalender).
+- SZ-Mischberechnung (§ 15j Abs 7 MSchG / § 8b Abs 7 VKG): B13/B14
+ bemessen je Monat aus dem jeweiligen (reduzierten) Monatsentgelt ×
+ Dienstzeitfaktor — VZ-/TZ-Anteile im Kalenderjahr ergeben sich
+ automatisch.
+- Endabrechnungs-Wizard: §-23-Abs-8-Hochrechnung (DV während
+ MSchG-/VKG-Teilzeit: frühere Normalarbeitszeit = Monatsentgelt ÷
+ work_time_rate, Vorschau); für Arbeitnehmerkündigung § 23a Abs 4a
+ (5-Jahres-Ø der geleisteten Arbeitszeit ohne Karenz, manuell);
+ UEL § 10 Abs 4 UrlG (überwiegende Arbeitszeit des Urlaubsjahres).
+ Dienstzeitanrechnung: Karenzzeiten je Kind in vollem Umfang
+ (§ 15f Abs 1 MSchG, Geburten ab 1. 8. 2019, max. 22./24. Lebens-
+ monat); Papa-Monat anrechnen (§ 1a Abs 7 VKG).
+
+## Offene Punkte (AP16-Reste)
+
+- **Urlaubs-Aliquotierung aus Karenz** (§ 15f Abs 2 MSchG: 30 WT/25 AT
+ : 365 × aktive KT, Teiltage aufrunden) greift in die hr_holidays-
+ Allokationsseite — eigener Folgeschritt; Urlaubsverjährung
+ verlängert sich um die Karenz (§ 4 Abs 5 UrlG).
+- **Krankheit während Karenz/Mutterschutz** ist als gleichzeitige
+ Abwesenheit in hr.leave nur mit einer Überlappungs-Policy-
+ Entscheidung umsetzbar (Odoo sperrt Quere-Überlappungen;
+ `allow_request_on_top` erfordert time_type „other") —
+ Mandanten-Entscheid; der Test umgeht mit dem offiziellen
+ leave_skip_date_check-Bypass.
+- Fristen der AG↔AN-Kommunikation (3-/2-Monats-Bekanntgaben,
+ 5/10-Arbeitstage, Begründungspflichten 5 Kalendertage) = Help-Texte;
+ keine Fristen-Automatik.
+- eSV-Sonderformate (Karenzart-Block „Familienhospizkarenz/Pflege-
+ karenz") und mBGM-Feinschliff = GP7; Sonderwochengeld-Vollrechnung
+ (§ 141 Abs 2/§ 125/§ 108f) = Anzeige-Stufe, kein Eigenrechenpfad.
+- lb-kar-02: Folgen der 13-Wochen-Überschreitung + § 2i-AVRAG-Konflikt
+ und lb-elt-06 §-8-/§-8a-VKG-Zuordnung — keine Implementierung auf
+ kontroversen Literaturansichten (dokumentiert ⚠).
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll_private/models/__init__.py b/addons/l10n_at_hr_payroll_private/models/__init__.py
index 2e1412f..6c0cfb4 100644
--- a/addons/l10n_at_hr_payroll_private/models/__init__.py
+++ b/addons/l10n_at_hr_payroll_private/models/__init__.py
@@ -14,6 +14,7 @@ from . import buchungsbeleg
from . import wien_dag
from . import efzg
from . import mutterschutz
+from . import karenz
from . import aeb
from . import pfaendung
from . import pfaendung_report
diff --git a/addons/l10n_at_hr_payroll_private/models/abfertigung_private.py b/addons/l10n_at_hr_payroll_private/models/abfertigung_private.py
index 5e0fa86..87f8a87 100644
--- a/addons/l10n_at_hr_payroll_private/models/abfertigung_private.py
+++ b/addons/l10n_at_hr_payroll_private/models/abfertigung_private.py
@@ -206,12 +206,18 @@ class HrPayslip(models.Model):
Erstfenster — 30-Tage-Monat (KB-Beispiel 4.500/30 × 17 =
2.550); Teilmonate über die Lohnsteuertage, um Doppelabzüge
zu vermeiden (das Entgelt ist für die Teilzeit bereits
- aliquotiert)."""
+ aliquotiert). AP16 (§ 6 Abs 4 BMSVG): während einer
+ Arbeitszeit-Reduzierung mit gesetztem Feld
+ l10n_at_bv_vor_entgelt (z. B. Pflegeteilzeit § 14d AVRAG)
+ läuft der Beitrag auf dem Vor-Reduzierungs-Entgelt."""
self.ensure_one()
pflichtig_ab, kurz_av = self._at_bv_fenster()
if kurz_av:
return 0.0
+ vor_entgelt = self.version_id.l10n_at_bv_vor_entgelt
basis = self._l10n_at_sv_basis_roh(result_rules)
+ if vor_entgelt:
+ basis = max(basis, float(vor_entgelt))
if pflichtig_ab is None or pflichtig_ab <= self.date_from:
# AP4: fiktive BG während des Krankengeldbezugs (§ 7 Abs 3
# BMSVG) und des Wochengeldbezugs (§ 7 Abs 4, AP7) — nur
diff --git a/addons/l10n_at_hr_payroll_private/models/efzg.py b/addons/l10n_at_hr_payroll_private/models/efzg.py
index 375df02..38c0ae4 100644
--- a/addons/l10n_at_hr_payroll_private/models/efzg.py
+++ b/addons/l10n_at_hr_payroll_private/models/efzg.py
@@ -275,7 +275,7 @@ class HrPayslip(models.Model):
self.ensure_one()
basis = self._l10n_at_sv_basis_roh(result_rules)
for code in ('TEIL_ENTGELT_HALB', 'KRANKENTGELT_PHASE',
- 'MSCH_ENTGELT'):
+ 'MSCH_ENTGELT', 'KARENZ_ENTGELT'):
if result_rules is not None and code in result_rules \
and result_rules[code]['total']:
basis -= result_rules[code]['total']
diff --git a/addons/l10n_at_hr_payroll_private/models/karenz.py b/addons/l10n_at_hr_payroll_private/models/karenz.py
new file mode 100644
index 0000000..42a326d
--- /dev/null
+++ b/addons/l10n_at_hr_payroll_private/models/karenz.py
@@ -0,0 +1,135 @@
+# Part of the odoo-at-payroll project. License: LGPL-3.
+"""AP16: Karenz-/Familien-Freistellungen — unbezahlte Karenz-Arten
+(Elternkarenz generisch, Bildungs-, Familienhospiz-, Kinderrehabilita-
+tions- und Pflegekarenz, Papa-Monat) mit Entgelt-Stopp, eSV-An- und
+Abmeldeereignissen und der BMSVG-§-7-Brücke.
+
+Am Originalwortlaut verifiziert (.ris/, 8.13 freigegeben 2026-09-15):
+- Alle Voll-Freistellungen sind entgeltlos (AVRAG §§ 11, 14a, 14b,
+ 14e, 14c; VKG § 1a) — Entgelt-Stopp je Arbeitstag über die
+ negative Regel ``KARENZ_ENTGELT`` (Muster MSCH_ENTGELT, AP7).
+- eSV: Abmeldung zum Ende des Entgeltanspruchs (Tag vor dem
+ Karenzbeginn), Anmeldung zum ersten Arbeitstag nach dem Ende;
+ Papamonat/Familienzeit: Abmeldung am Tag vor Beginn (§ 11 Abs 3
+ lit a ASVG, lb-pap-01). Karenzart-Strings der Sonderformate
+ „Familienhospizkarenz/Pflegekarenz An-/Abmeldung" (lb-kzs-05)
+ laufen als Protokoll-Info — erweiterte eSV-Sonderformate = GP7-Rest.
+- BV: Elternkarenz/KBG zu Lasten FLAF (§ 7 Abs 5 BMSVG), Familien-
+ hospiz-/Pflegekarenz zu Lasten des Bundes (§ 7 Abs 6 BMSVG,
+ Fiktivgrundlage KBG § 5b Abs 1 KBGG idF vor BGBl I 53/2016);
+ Pflegeteilzeit: AG-Beitrag auf Vor-Reduzierungs-Entgelt (§ 6 Abs 4
+ BMSVG, Feld l10n_at_bv_vor_entgelt).
+- Dienstzeit: Papamonat ist ANZURECHNEN (§ 1a Abs 7 VKG iVm § 15f
+ MSchG), Bildungs-/Pflegekarenz grundsätzlich NICHT (§ 11 Abs 2,
+ § 14c/14d AVRAG) — Wizard-bestätigte Dienstzeiten, dokumentiert.
+"""
+from datetime import timedelta
+
+from odoo import fields, models
+
+# Unbezahlte Karenz-WETs (generisch + AP16-Spezies)
+_KARENZ_WET = 'ATP_KARENZ'
+_BILDUNGSKARENZ_WET = 'ATP_BILDUNGSKARENZ'
+_HOSPIZKARENZ_WET = 'ATP_HOSPIZKARENZ'
+_KINDER_KRANK_WET = 'ATP_KINDERKRANK'
+_KINDER_REHAB_WET = 'ATP_KINDERREHAB'
+_PFLEGEKARENZ_WET = 'ATP_PFLEGEKARENZ'
+_PAPAMONAT_WET = 'ATP_PAPAMONAT'
+
+KARENZ_ARTEN = {
+ _KARENZ_WET: 'Elternkarenz (MSchG/VKG)',
+ _BILDUNGSKARENZ_WET: 'Bildungskarenz (§ 11 AVRAG)',
+ _HOSPIZKARENZ_WET: 'Familienhospizkarenz — Sterbebegleitung '
+ '(§ 14a AVRAG)',
+ _KINDER_KRANK_WET: 'Familienhospizkarenz — Begleitung schwerst '
+ 'erkrankter Kinder (§ 14b AVRAG)',
+ _KINDER_REHAB_WET: 'Freistellung Kinderrehabilitation '
+ '(§ 14e AVRAG)',
+ _PFLEGEKARENZ_WET: 'Pflegekarenz (§ 14c AVRAG)',
+ _PAPAMONAT_WET: 'Papa-Monat/Familienzeit (§ 1a VKG / FamZeitbG)',
+}
+
+
+class HrPayslip(models.Model):
+ _inherit = 'hr.payslip'
+
+ def _at_karenz_wet_codes(self):
+ """Codes der unbezahlten Karenz-Freistellungen (inkl. generisch
+ und AP16-Spezies)."""
+ self.ensure_one()
+ return list(KARENZ_ARTEN)
+
+ def _at_karenz_leaves(self):
+ """Validierte Karenz-Freistellungen: [(von, bis, wett_code)]
+ chronologisch."""
+ self.ensure_one()
+ typen = self.env['hr.leave.type'].search([
+ ('work_entry_type_id.code', 'in', self._at_karenz_wet_codes())
+ ])
+ leaves = self.env['hr.leave'].search([
+ ('employee_id', '=', self.employee_id.id),
+ ('holiday_status_id', 'in', typen.ids),
+ ('state', 'in', ('validate', 'validate1')),
+ ])
+ ergebnis = []
+ for leave in leaves:
+ wett = leave.holiday_status_id.work_entry_type_id.code
+ ergebnis.append((leave.request_date_from,
+ leave.request_date_to, wett))
+ return [(von, bis, wett) for von, bis, wett in ergebnis
+ if von and bis]
+
+ def _at_karenz_status(self):
+ """Karenz-Tage je Abrechnungsperiode über alle unbezahlten
+ Karenz-Arten: {'kalendertage', 'arbeitstage', 'arten'} — None
+ ohne Freistellung im Zeitraum (Muster _at_msch_status)."""
+ self.ensure_one()
+ codes = set(self._at_karenz_wet_codes())
+ tage = {}
+ tag_art = {}
+ for von, bis, wett in self._at_karenz_leaves():
+ tag = fields.Date.to_date(von)
+ ende = fields.Date.to_date(bis)
+ while tag <= ende:
+ tage[tag] = True
+ tag_art[tag] = wett
+ tag += timedelta(days=1)
+ in_periode = sorted(
+ tag for tag in tage
+ if self.date_from <= tag <= self.date_to)
+ if not in_periode:
+ return None
+ eintraege = self.env['hr.work.entry'].search([
+ ('employee_id', '=', self.employee_id.id),
+ ('date', '>=', self.date_from),
+ ('date', '<=', self.date_to),
+ ('state', '!=', 'cancelled'),
+ ('work_entry_type_id.code', 'in', list(codes)),
+ ])
+ arten_aktuell = {tag_art[tag] for tag in in_periode}
+ return {
+ 'kalendertage': in_periode,
+ 'arbeitstage': sorted({e.date for e in eintraege}),
+ 'arten': sorted(KARENZ_ARTEN[c] for c in arten_aktuell),
+ }
+
+ def _at_karenz_entgelt_tage(self, result_rules=None):
+ """Arbeitstage der unbezahlten Karenz im Abrechnungszeitraum
+ (Entgelt-Stopp-Basis der Regel KARENZ_ENTGELT)."""
+ self.ensure_one()
+ status = self._at_karenz_status()
+ if not status:
+ return 0
+ return len(status['arbeitstage'])
+
+
+class HrVersion(models.Model):
+ _inherit = 'hr.version'
+
+ l10n_at_bv_vor_entgelt = fields.Monetary(
+ string='BV-Basis vor Arbeitszeit-Reduktion (§ 6 Abs 4 BMSVG)',
+ currency_field='currency_id',
+ help='Monatsentgelt vor der Reduzierung (z. B. Pflegeteilzeit '
+ '§ 14d AVRAG): der BV-Beitrag läuft während der Maßnahme '
+ 'auf Grundlage der Arbeitszeit vor Herabsetzung '
+ '(§ 6 Abs 4 BMSVG). Leer = laufende Basis.')
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll_private/models/mutterschutz.py b/addons/l10n_at_hr_payroll_private/models/mutterschutz.py
index e8f3e4e..35b573c 100644
--- a/addons/l10n_at_hr_payroll_private/models/mutterschutz.py
+++ b/addons/l10n_at_hr_payroll_private/models/mutterschutz.py
@@ -36,11 +36,27 @@ Fristberechnung aus dem ärztlichen Zeugnis bleibt beim Mandanten.
Urlaubsanspruch erwächst während des Wochengeldbezugs (lb-msf-03);
Sonderwochengeld (§ 163 ASVG, Günstigkeitsvergleich § 162 Abs 3b) =
dokumentierte Option.
+
+AP16 (8.13): Karenz-Brücke nach § 7 Abs 4 Z 1–3 BMSVG — folgt das
+neuerliche Beschäftigungsverbot unmittelbar auf eine Karenz (Z 1) oder
+nach einer kürzeren als dreimonatigen Beschäftigung (Z 2), ist die
+Bemessungsgrundlage die des Kalendermonats vor dem der Karenz
+unmittelbar vorangegangenen Beschäftigungsverbots; Z 3 (neues DV nach
+Beendigung des karenzierten DV, < 3 Monate): der letzte volle
+Kalendermonat vor dem neuerlichen Verbot. BV während des KBG-Bezugs
+läuft zu Lasten des FLAF (§ 7 Abs 5), in der Familienhospiz-/Pflege-
+karenz zu Lasten des Bundes (§ 7 Abs 6) — keine AG-Regeln; das Ruhen
+des Wochengeldes (§ 166 ASVG) und das Sonderwochengeld während
+einer Karenz (§ 163 ASVG) werden als Info/Warnung ausgewiesen.
"""
from datetime import date, timedelta
+from dateutil.relativedelta import relativedelta
+
from odoo import fields, models
+from .karenz import KARENZ_ARTEN
+
_MSCH_WET = 'ATP_MSCH'
@@ -61,13 +77,21 @@ class HrPayslip(models.Model):
slip.l10n_at_msch_info = ''
continue
wochengeld = slip._at_wochengeld()
- slip.l10n_at_msch_info = (
+ info = (
'%d Kalendertage Bezug · WG %.2f/Kalendertag '
'(Ø-Netto %.2f + %.0f %%)' % (
len(msch['kalendertage']),
wochengeld['tagesatz'],
wochengeld['netto_drei_monate'],
wochengeld['sz_prozent']))
+ if slip._at_msch_sonderwochengeld():
+ info += (' · Sonderwochengeld (§ 163 ASVG) statt '
+ 'Wochengeld — ÖGK, erhöhtes Krankengeld '
+ '(§ 141 Abs 2, § 125 iVm § 108f)')
+ warnungen = slip._at_msch_ruhen_warnungen()
+ if warnungen:
+ info += ' · Ruhen: ' + '; '.join(warnungen)
+ slip.l10n_at_msch_info = info
def _at_msch_leaves(self):
"""Validierte Mutterschutz-Frist-Abwesenheiten (ATP_MSCH)."""
@@ -120,17 +144,109 @@ class HrPayslip(models.Model):
'episode_beginn': block,
}
- def _at_msch_referenz_monate(self, episode_beginn):
- """Die drei (vollen) Kalendermonate vor dem Versicherungsfall
- der Mutterschaft (§ 162 Abs 3: letzte 13 Wochen; § 7 Abs 4
- BMSVG: letzte drei Kalendermonate). Versicherungsfall im Monat
- X → Monate X-3, X-2, X-1 (aufsteigend)."""
+ def _at_msch_sonderwochengeld(self):
+ """Sonderwochengeld statt Wochengeld (§ 163 ASVG, verifiziert):
+ die Mutterschutz-Frist fällt in eine Karenz nach dem MSchG —
+ kein Anspruch auf Wochengeld (§ 162), das Sonderwochengeld
+ zahlt der Krankenversicherungsträger (erhöhtes Krankengeld
+ § 141 Abs 2, Bemessungsgrundlage § 125 aus dem Arbeits-
+ verdienst vor dem Ende des letzten Entgeltanspruchs, mit
+ § 108f-Anpassung)."""
+ self.ensure_one()
+ msch = self._at_msch_status()
+ if not msch:
+ return False
+ for von, bis, _wett in self._at_karenz_leaves():
+ if any(von <= tag <= bis for tag in msch['kalendertage']):
+ return True
+ return False
+
+ def _at_msch_ruhen_warnungen(self):
+ """Ruhen des Wochengeldes (§ 166 ASVG, verifiziert) —
+ Warnungen zu Fortbezug während der Frist:
+ Z 2 Entgeltfortzahlung 100 % → Ruhen zur Gänze, 50 % →
+ hälftig (Urlaubs-/UEL-/KE-Zeiten gelten nicht als weiterge-
+ leistete Bezüge); Z 3 Erwerbstätigkeit und Z 4 Pflegekarenz-
+ geld sind Bezugszeitraum-Daten (AEB/ÖGK-Seite, Hinweis)."""
+ self.ensure_one()
+ msch = self._at_msch_status()
+ if not msch:
+ return []
+ tage = set(msch['kalendertage'])
+ efz = self._at_efz_status()
+ warnungen = []
+ if efz:
+ if any(tag in tage for tag in efz.get('voll_arbeitstage', [])):
+ warnungen.append('Fortbezug > 50 % (Entgeltfortzahlung) '
+ '— Ruhen zur Gänze (§ 166 Abs 1 Z 2)')
+ elif any(tag in tage for tag in efz.get('halb_arbeitstage', [])):
+ warnungen.append('Fortbezug = 50 % — Ruhen hälftig '
+ '(§ 166 Abs 1 Z 2)')
+ warnungen.append('Erwerbstätigkeit/Pflegekarenzgeld während der '
+ 'Frist prüfen (§ 166 Abs 1 Z 3/4)')
+ return warnungen
+
+ def _at_msch_referenz_stichtag_und_anzahl(self, episode_beginn):
+ """(Stichtag, Referenz-Monatsanzahl) nach § 7 Abs 4 BMSVG mit
+ Karenz-Brücke Z 1–3 (AP16):
+ - Z 1/Z 2: endete eine Karenz innerhalb von drei Monaten vor
+ dem neuerlichen Beschäftigungsverbot, gilt das der Karenz
+ unmittelbar vorangegangene Beschäftigungsverbot als
+ Bemessungsstichtag (Referenzmonate davor);
+ - Z 3: neues DV binnen drei Monaten nach Beendigung des
+ karenzierten DV (mit Karenz) → der letzte volle Kalendermonat
+ vor dem neuerlichen Verbot als EINZELNER Referenzmonat
+ („volles Monatsentgelt");
+ - sonst: Versicherungsfall selbst, drei Monate."""
self.ensure_one()
stichtag = episode_beginn or self.date_from
+ grenze = stichtag - relativedelta(months=3)
+ karenz_kandidat = None
+ for von, bis, _wett in sorted(self._at_karenz_leaves(),
+ key=lambda k: k[1], reverse=True):
+ if grenze <= bis < stichtag:
+ karenz_kandidat = (von, bis)
+ break
+ if karenz_kandidat:
+ karenz_von = karenz_kandidat[0]
+ frist = max(
+ (m for m in self._at_msch_leaves()
+ if m[1] <= karenz_von),
+ key=lambda m: m[1], default=None)
+ if frist:
+ return frist[0], 3
+ version = self.version_id
+ vorher = self.env['hr.version'].search([
+ ('employee_id', '=', self.employee_id.id),
+ ('contract_date_end', '!=', False),
+ ('id', '!=', version.id),
+ ], order='contract_date_end desc', limit=1)
+ if (vorher and vorher.contract_date_end
+ and version.contract_date_start
+ and vorher.contract_date_end
+ <= version.contract_date_start
+ and (version.contract_date_start
+ - vorher.contract_date_end).days < 92
+ and any(k[1] <= vorher.contract_date_end
+ for k in self._at_karenz_leaves())):
+ erster = date(stichtag.year, stichtag.month, 1)
+ erster = (erster - timedelta(days=1)).replace(day=1)
+ return erster, 1
+ return stichtag, 3
+
+ def _at_msch_referenz_monate(self, episode_beginn):
+ """Referenzkalendermonate der fiktiven BV (§ 7 Abs 4 BMSVG:
+ letzte drei Monate; Karenz-Brücke Z 1–3, AP16 — s.
+ _at_msch_referenz_stichtag_und_anzahl; § 162 Abs 3: letzte
+ 13 Wochen). Versicherungsfall im Monat X → Monate X-3, X-2,
+ X-1 (aufsteigend)."""
+ self.ensure_one()
+ stichtag, anzahl = self._at_msch_referenz_stichtag_und_anzahl(
+ episode_beginn)
erster = date(stichtag.year, stichtag.month, 1)
monate = []
hilf = erster
- for _ in range(3):
+ for _ in range(anzahl):
hilf = (hilf - timedelta(days=1)).replace(day=1)
monate.append(hilf)
return list(reversed(monate))
diff --git a/addons/l10n_at_hr_payroll_private/models/versichertenmeldung.py b/addons/l10n_at_hr_payroll_private/models/versichertenmeldung.py
index f2456fc..257beef 100644
--- a/addons/l10n_at_hr_payroll_private/models/versichertenmeldung.py
+++ b/addons/l10n_at_hr_payroll_private/models/versichertenmeldung.py
@@ -23,12 +23,15 @@ BVAB/BVJN aus dem GP5-BMSVG-Flag, VWAZ vereinbarte Wochenarbeitszeit
(Stunden ×100, neu für Meldedaten ab 01.01.2026).
Dateibasiert (Prämisse P7); Bestätigungen/Übertragung beim Mandanten."""
-from datetime import date
+from datetime import date, timedelta
import base64
from odoo import api, fields, models
from odoo.exceptions import UserError
+from odoo.addons.l10n_at_hr_payroll_private.models.karenz import \
+ KARENZ_ARTEN
+
from odoo.addons.l10n_at_hr_payroll.models.elda import (
L10N_AT_ELDA_IDTEIL,
L10nAtEldaBestand,
@@ -199,6 +202,58 @@ class L10nAtPayrollVr(models.Model):
abmeldungen.append(version)
return sorted(abmeldungen, key=lambda v: v.contract_date_end)
+ def _l10n_at_offene_karenz_events(self):
+ """AP16 (8.13): An-/Abmeldungen aus validierten Karenz-
+ Freistellungen: M4 zum Ende des Entgeltanspruchs (Tag vor dem
+ Karenzbeginn; Familienzeit am Tag vor Beginn, § 11 Abs 3 lit a
+ ASVG, lb-pap-01), M3 zum ersten Arbeitstag nach dem Karenzende
+ — entfällt, wenn das DV mit der Karenz endet (normale M4-
+ Vertragsabmeldung deckt das Ende). Karenzart-Strings für die
+ eSV-Sonderformate „Familienhospizkarenz/Pflegekarenz An-/
+ Abmeldung" (lb-kzs-05) laufen als Protokoll-Info; erweiterte
+ eSV-Sonderformate = GP7-Rest. Dedup über die Registry
+ (employee, art, adatum); keine Events nach dem Stichtag."""
+ self.ensure_one()
+ typen = self.env['hr.leave.type'].search([
+ ('work_entry_type_id.code', 'in', list(KARENZ_ARTEN))])
+ if not typen:
+ return []
+ leaves = self.env['hr.leave'].search([
+ ('company_id', 'in', self.company_id.ids),
+ ('holiday_status_id', 'in', typen.ids),
+ ('state', 'in', ('validate', 'validate1')),
+ ]).sorted('request_date_from')
+ registry = self.env['l10n.at.payroll.vr.event'].search([
+ ('company_id', '=', self.company_id.id),
+ ])
+ gesehen = {(e.employee_id.id, e.art, e.adatum) for e in registry}
+ ereignisse = []
+ for leave in leaves:
+ von = leave.request_date_from
+ bis = leave.request_date_to
+ if not von or not bis:
+ continue
+ version = leave.employee_id.version_id
+ if not version:
+ continue
+ wett_code = leave.holiday_status_id \
+ .work_entry_type_id.code
+ karenzart = KARENZ_ARTEN.get(wett_code, 'Karenz')
+ abmeldetag = von - timedelta(days=1)
+ if abmeldetag > self.stichtag:
+ continue
+ if (leave.employee_id.id, 'M4', abmeldetag) not in gesehen:
+ ereignisse.append((version, 'M4', abmeldetag, karenzart))
+ rueckkehr = bis + timedelta(days=1)
+ if (version.contract_date_end
+ and rueckkehr >= version.contract_date_end):
+ continue
+ if rueckkehr > self.stichtag:
+ continue
+ if (leave.employee_id.id, 'M3', rueckkehr) not in gesehen:
+ ereignisse.append((version, 'M3', rueckkehr, karenzart))
+ return ereignisse
+
def _l10n_at_bber(self, version):
if version.l10n_at_lehrling:
return '03' if getattr(
@@ -234,6 +289,7 @@ class L10nAtPayrollVr(models.Model):
raise UserError('Beitragskontonummer (BKNR) fehlt.')
datensaetze = []
ereignisse = []
+ karenz_info = []
vstr = bknr[:2]
if lauf.nur_fallweise:
versionen = self.env['hr.version'].search([
@@ -366,6 +422,51 @@ class L10nAtPayrollVr(models.Model):
ereignisse.append((version, 'M4',
version.contract_date_end,
refw))
+ for version, art, adatum, karenzart in \
+ lauf._l10n_at_offene_karenz_events():
+ if not version.ssnid:
+ fehler.append(
+ 'Karenz-Meldung %s ohne Versicherungsnummer.'
+ % version.employee_id.name)
+ continue
+ satz = L10nAtEldaSatz(
+ L10N_AT_ELDA_IDTEIL + VR_SATZ, 772,
+ bezeichnung='%s Karenz %s' % (
+ art, version.employee_id.name))
+ refw = '%s-K%d-%s-%d' % (
+ art, company.id,
+ adatum.strftime('%Y%m%d'),
+ version.employee_id.id)
+ satz.setze('SART', art)
+ satz.setze('VSTR', vstr)
+ satz.setze('REFW', refw)
+ satz.setze('BKNR', bknr)
+ satz.setze('DGNA', company.name[:70])
+ satz.setze('VSNR', version.ssnid)
+ satz.setze_datum(
+ 'GEBD', version.employee_id.birthday)
+ familienname, vorname = l10n_at_namen(
+ version.employee_id)
+ satz.setze('FANA', familienname[:70])
+ satz.setze('VONA', (vorname or '')[:70])
+ satz.setze_datum('ADAT', adatum)
+ if art == 'M3':
+ satz.setze('BBER', lauf._l10n_at_bber(version))
+ satz.setze('GERF', 'N')
+ satz.setze('FRDV', 'N')
+ else:
+ satz.setze_datum('EBSV', adatum)
+ satz.setze('AGRD', '00')
+ kalender = version.resource_calendar_id
+ if kalender:
+ satz.setze('VWAZ', kalender.hours_per_week,
+ skala=2)
+ datensaetze.append(satz)
+ ereignisse.append((version, art, adatum, refw))
+ karenz_info.append(
+ '%s (ADAT %s) — %s' % (
+ version.employee_id.name,
+ adatum.strftime('%d.%m.%Y'), karenzart))
if fehler:
lauf.protokoll = 'FEHLER:\n' + '\n'.join(fehler)
raise UserError('Versichertenmeldung mit Fehlern '
@@ -395,12 +496,17 @@ class L10nAtPayrollVr(models.Model):
ereignisse = self.env['l10n.at.payroll.vr.event'].create(
ereignis_werte) if ereignis_werte else \
self.env['l10n.at.payroll.vr.event']
+ protokoll = 'Meldungen: %d' % len(datensaetze)
+ if karenz_info:
+ protokoll += ("\nKarenz (eSV-Sonderformate GP7-Rest; "
+ "Karenzart zur Übergabe):\n"
+ + "\n".join(karenz_info))
lauf.write({
'datei': base64.b64encode(datei.render()),
'datei_name': 'versichertenmeldung_%s.txt' % (
fields.Date.today().strftime('%Y%m%d'),),
'event_ids': [(6, 0, ereignisse.ids)],
'state': 'done',
- 'protokoll': 'Meldungen: %d' % len(datensaetze),
+ 'protokoll': protokoll,
})
return True
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll_private/tests/__init__.py b/addons/l10n_at_hr_payroll_private/tests/__init__.py
index 7eea1cf..45caadb 100644
--- a/addons/l10n_at_hr_payroll_private/tests/__init__.py
+++ b/addons/l10n_at_hr_payroll_private/tests/__init__.py
@@ -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
diff --git a/addons/l10n_at_hr_payroll_private/tests/test_karenz_private.py b/addons/l10n_at_hr_payroll_private/tests/test_karenz_private.py
new file mode 100644
index 0000000..1f62b22
--- /dev/null
+++ b/addons/l10n_at_hr_payroll_private/tests/test_karenz_private.py
@@ -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)
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml b/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml
index 05ff3d8..ac83d9b 100644
--- a/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml
+++ b/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml
@@ -38,6 +38,8 @@
+
diff --git a/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py b/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py
index 0785614..b9900d0 100644
--- a/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py
+++ b/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py
@@ -95,6 +95,17 @@ class L10nAtEndabrechnungWizard(models.TransientModel):
'entfallende tarifmäßige Lohnsteuer nach den persönlichen '
'Verhältnissen. Bei steuerfreiem Monatslohn ausdrücklich 0 '
'bestätigen.')
+ abf_alt_hochrechnung_vorschau = fields.Monetary(
+ string='§ 23 Abs 8 AngG — Hochrechnung Vollzeit (Vorschau)',
+ currency_field='currency_id', compute='_compute_vorschau',
+ help='Endet das DV während einer Teilzeitbeschäftigung nach '
+ 'MSchG/VKG (z. B. Elternteilzeit), ist die frühere '
+ 'Normalarbeitszeit zugrunde zu legen (§ 23 Abs 8 AngG; für '
+ 'Arbeitnehmerkündigung § 23a Abs 4a — 5-Jahres-Ø ohne '
+ 'Karenz, manuell einzugeben). Vorschlag: Monatsentgelt ÷ '
+ 'aktuelles work_time_rate; die Übernahme bleibt '
+ 'bestätigt (bei UEL-Basen gilt § 10 Abs 4 UrlG — '
+ 'überwiegende Arbeitszeit des Urlaubsjahres).')
abf_alt_mehrfach = fields.Float(
string='Bestätigter Anspruchsfaktor',
help='§-23-Staffel, gegebenenfalls halbiert/gedeckelt oder als '
@@ -404,6 +415,7 @@ class L10nAtEndabrechnungWizard(models.TransientModel):
wizard.abf_alt_steuer_6_vorschau = 0.0
wizard.abf_alt_lohnsteuer_vorschau = 0.0
wizard.abf_alt_netto_vorschau = 0.0
+ wizard.abf_alt_hochrechnung_vorschau = 0.0
wizard.abf_frei_z1_vorschau = 0.0
wizard.abf_frei_z2_vorschau = 0.0
wizard.abf_frei_kuerzung_vorschau = 0.0
@@ -434,6 +446,12 @@ class L10nAtEndabrechnungWizard(models.TransientModel):
wizard.abf_alt_steuer_6_vorschau = steuer_6
wizard.abf_alt_lohnsteuer_vorschau = steuer
wizard.abf_alt_netto_vorschau = betrag - steuer
+ rate = wizard.employee_id.version_id.work_time_rate
+ if rate and rate < 1.0 and wizard.abf_alt_monatsentgelt:
+ # § 23 Abs 8 AngG: frühere Normalarbeitszeit —
+ # Hochrechnungs-Vorschlag, bestätigte Übernahme.
+ wizard.abf_alt_hochrechnung_vorschau = \
+ wizard.abf_alt_monatsentgelt / rate
if wizard.abf_frei_aktiv:
split = slip._at_abf_frei_split()
satz = float(slip._l10n_at_param(