d53e1e64a1
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)
309 lines
14 KiB
Python
309 lines
14 KiB
Python
# Part of the odoo-at-payroll project. License: LGPL-3.
|
|
"""EFZG/Teilentgelt (AP4) — Entgeltfortzahlung im Krankenstand.
|
|
|
|
Rechtsgrundlagen (verifiziert 2026-09-12):
|
|
- **AngG § 8 Abs 1/2/2a** und **EFZG § 2 Abs 1/3, § 2 Abs 5**
|
|
(Gleichlauf seit 1. 7. 2018, BGBl I 153/2017; EFZG RIS GesNr
|
|
10008308, AngG lokal — Stufen 6/8/10/12 Wochen je vollendeten
|
|
Dienstjahren <1 / 1-14 / 15-24 / ≥25, zuzüglich je 4 Wochen halbes
|
|
Entgelt; wiederholte Verhinderungen im selben Arbeitsjahr
|
|
zusammengerechnet; AU/BK-Sonderkontingent 8/10 Wochen ohne
|
|
Halbsatz, je Anlassfall, unabhängig vom Krankheits-Topf).
|
|
- **lb-krs-08/02/07** (Wissensbasis, Layer 2): Arbeitsjahrprinzip,
|
|
Kalendertage als Zähleinheit, Stufensprung im laufenden Stand
|
|
verlängert; SV: volle EFZ beitragspflichtig, EFZ < 50 %
|
|
beitragsfrei (hier: Halbsatz = 50 % → pflichtig); Lohnsteuer:
|
|
laufender Bezug; **BMSVG § 7 Abs 3**: während des Krankengeldbezugs
|
|
(Halb- und ÖGK-Phase) fiktive BG = 50 % des Entgelts des
|
|
Kalendermonats vor dem Versicherungsfall, SZ ausgenommen.
|
|
- Krankengeld selbst zahlt die ÖGK (läuft nicht durch den
|
|
Dienstgeber-Payslip; lb-krs-02) — der Payroll-Anteil ist die
|
|
Entgelt-Reduktion des Dienstgebers, die fiktive BV-Grundlage und
|
|
später die Arbeits-/Entgeltbestätigung (AP8).
|
|
- „Krankengeldumlage § 62 ASVG" ist eine Falsch-Prämise
|
|
(RECHTSQUELLEN-Privat 2026-09-10) — kein Umlage-Baustein.
|
|
|
|
Zähleinheiten: Kontingente in **Kalendertagen** (lb-krs-08: Praxis),
|
|
bezogen aus den validierten Abwesenheiten (hr.leave der AT-Urlaubsarten
|
|
Krankenstand/Arbeitsunfall); die **Entgelt-Minderung** je
|
|
Arbeitstag aus dem Arbeitseintragssatz (Tagesatz = laufende
|
|
Monatsbasis ÷ Arbeitstage des Monats — Entgeltausfallsprinzip).
|
|
Feiertage im Krankenstand werden vom Work-Entry-Modell automatisch
|
|
nicht als Krankenstandstage erzeugt (Kalender) — die 100 %-
|
|
Feiertagsentgelt-Regel lb-krs-08 bleibt damit gewahrt.
|
|
"""
|
|
from datetime import date, timedelta
|
|
|
|
from odoo import fields, models
|
|
|
|
# WET-Codes der Abwesenheits-Töpfe (AP5-Seed).
|
|
_KRANK_WET = 'ATP_KRANK'
|
|
_AU_WET = 'ATP_AU'
|
|
|
|
|
|
class HrVersion(models.Model):
|
|
_inherit = 'hr.version'
|
|
|
|
def _l10n_at_efz_arbeitsjahr_start(self, stichtag):
|
|
"""Beginn des Arbeitsjahres, das den Stichtag enthält
|
|
(Eintrittsdatum-Rhythmus, lb-krs-08). None ohne
|
|
Vertragsbeginn."""
|
|
self.ensure_one()
|
|
beginn = self.contract_date_start
|
|
if not beginn:
|
|
return None
|
|
jahre = beginn.year
|
|
while True:
|
|
aj_start = date(jahre, beginn.month, beginn.day) \
|
|
if not (beginn.month == 2 and beginn.day == 29) \
|
|
else date(jahre, 3, 1)
|
|
aj_naechstes = date(jahre + 1, aj_start.month, aj_start.day)
|
|
if aj_start <= stichtag < aj_naechstes:
|
|
return aj_start
|
|
jahre += 1
|
|
if jahre > beginn.year + 80:
|
|
return None
|
|
|
|
def _l10n_at_efz_phasen(self, aj_start, endtag, stichtag=None):
|
|
"""Komplette EFZ-Phasen-Karte des Arbeitsjahres über alle
|
|
Krank-/AU-Kalendertage bis ``endtag``: {datum: 'voll'/'halb'/
|
|
'oegk'} plus Kontext-Parameter — gemeinsame Basis der
|
|
Payslip-Auswertung (AP4) und der Arbeits- und
|
|
Entgeltbestätigung (AP8).
|
|
|
|
Stufen je Tag (Stufensprung verlängert, lb-krs-08),
|
|
Krank-/AU-Topf getrennt (AU ohne Halbsatz, § 8 Abs 2a AngG /
|
|
§ 2 Abs 5 EFZG)."""
|
|
self.ensure_one()
|
|
stichtag = stichtag or fields.Date.context_today(self)
|
|
params = self.env['hr.rule.parameter']._get_parameter_from_code(
|
|
'at_efz_stufen', stichtag, raise_if_not_found=False) or {}
|
|
stufen = sorted(
|
|
(int(jahre), int(wochen))
|
|
for jahre, wochen in params.get('stufen', []))
|
|
halb_wochen = int(params.get('halb_wochen', 4))
|
|
au_wochen = int(params.get('au_wochen', 8))
|
|
au2_wochen = int(params.get('au2_wochen', 10))
|
|
au_ab_jahre = int(params.get('au_ab_jahre', 15))
|
|
typen = self.env['hr.leave.type'].search([
|
|
('work_entry_type_id.code', 'in', (_KRANK_WET, _AU_WET))])
|
|
leaves = self.env['hr.leave'].search([
|
|
('employee_id', '=', self.employee_id.id),
|
|
('holiday_status_id', 'in', typen.ids),
|
|
('state', 'in', ('validate', 'validate1')),
|
|
])
|
|
krank_tage, au_tage = {}, {}
|
|
for leave in leaves:
|
|
if not (leave.request_date_from and leave.request_date_to):
|
|
continue
|
|
tag = leave.request_date_from
|
|
while tag <= leave.request_date_to:
|
|
if aj_start <= tag <= endtag:
|
|
topf = au_tage if leave.holiday_status_id \
|
|
.work_entry_type_id.code == _AU_WET else krank_tage
|
|
topf[tag] = True
|
|
tag += timedelta(days=1)
|
|
beginn = self.contract_date_start
|
|
|
|
def stufe_fuer(dienstjahre, tag_ist_au):
|
|
wochen = stufen[0][1] if stufen else 6
|
|
for jahre, stufe in stufen:
|
|
if dienstjahre >= jahre:
|
|
wochen = stufe
|
|
if tag_ist_au:
|
|
wochen = au2_wochen if dienstjahre >= au_ab_jahre \
|
|
else au_wochen
|
|
return wochen
|
|
|
|
phasen = {}
|
|
verbraucht_krank_voll = verbraucht_krank_halb = 0
|
|
verbraucht_au = 0
|
|
for tag in sorted(set(krank_tage) | set(au_tage)):
|
|
dienstjahre = (tag.year - beginn.year
|
|
- ((tag.month, tag.day) < (beginn.month,
|
|
beginn.day)))
|
|
if tag in au_tage:
|
|
if verbraucht_au < stufe_fuer(dienstjahre, True) * 7:
|
|
phasen[tag] = 'voll'
|
|
else:
|
|
phasen[tag] = 'oegk'
|
|
verbraucht_au += 1
|
|
continue
|
|
wochen = stufe_fuer(dienstjahre, False)
|
|
if verbraucht_krank_voll < wochen * 7:
|
|
phasen[tag] = 'voll'
|
|
verbraucht_krank_voll += 1
|
|
elif verbraucht_krank_halb < halb_wochen * 7:
|
|
phasen[tag] = 'halb'
|
|
verbraucht_krank_halb += 1
|
|
else:
|
|
phasen[tag] = 'oegk'
|
|
return {
|
|
'phasen': phasen,
|
|
'stufen': stufen,
|
|
'halb_wochen': halb_wochen,
|
|
'au_wochen': au_wochen,
|
|
'au2_wochen': au2_wochen,
|
|
'au_ab_jahre': au_ab_jahre,
|
|
}
|
|
|
|
def _l10n_at_efz_stufe_am(self, stichtag, ist_au=False):
|
|
"""EFZ-Stufen-Wochen am Stichtag (vollendete Dienstjahre)."""
|
|
self.ensure_one()
|
|
aj_start = self._l10n_at_efz_arbeitsjahr_start(stichtag)
|
|
if not aj_start:
|
|
return None
|
|
kontext = self._l10n_at_efz_phasen(aj_start, stichtag)
|
|
beginn = self.contract_date_start
|
|
dienstjahre = (stichtag.year - beginn.year
|
|
- ((stichtag.month, stichtag.day)
|
|
< (beginn.month, beginn.day)))
|
|
stufen = kontext['stufen']
|
|
wochen = stufen[0][1] if stufen else 6
|
|
for jahre, stufe in stufen:
|
|
if dienstjahre >= jahre:
|
|
wochen = stufe
|
|
if ist_au:
|
|
wochen = kontext['au2_wochen'] \
|
|
if dienstjahre >= kontext['au_ab_jahre'] \
|
|
else kontext['au_wochen']
|
|
return wochen
|
|
|
|
|
|
class HrPayslip(models.Model):
|
|
_inherit = 'hr.payslip'
|
|
|
|
l10n_at_efz_info = fields.Char(
|
|
string='AT EFZ-Status (Krankenstand)', compute='_compute_efz_info',
|
|
help='Phasenstatus des Krankenstands im Abrechnungszeitraum: '
|
|
'volle/halbe Entgeltfortzahlung, ÖGK-Krankengeld-Phase, '
|
|
'Kontingente und Stufe (AngG § 8 / EFZG § 2).')
|
|
|
|
def _compute_efz_info(self):
|
|
for slip in self:
|
|
efz = slip._at_efz_status()
|
|
if not efz:
|
|
slip.l10n_at_efz_info = ''
|
|
continue
|
|
slip.l10n_at_efz_info = (
|
|
'Stufe %d (%d+%d Wo) · voll %d AT · halb %d AT · '
|
|
'ÖGK %d AT' % (
|
|
efz['stufe_wochen'], efz['stufe_wochen'],
|
|
efz['halb_wochen'], len(efz['voll_arbeitstage']),
|
|
len(efz['halb_arbeitstage']),
|
|
len(efz['oegk_arbeitstage'])))
|
|
|
|
# ------------------------------------------------------------------
|
|
# EFZ-Phasenbestimmung (Phasen-Karte: hr.version, AP8-Refaktur)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _at_efz_status(self):
|
|
"""EFZ-Phasen je Abrechnungsperiode: {'stufe_wochen',
|
|
'halb_wochen', 'voll_arbeitstage', 'halb_arbeitstage',
|
|
'oegk_arbeitstage', 'oegk_kalendertage', 'episode_beginn'}
|
|
— None ohne Krankenstand/AU im Arbeitsjahr.
|
|
|
|
Die Phasen-Karte (Kalendertage, Stufen je Tag, Krank-/AU-Topf
|
|
getrennt, § 8 Abs 2a AngG / § 2 Abs 5 EFZG) liefert
|
|
``hr.version._l10n_at_efz_phasen`` — gemeinsame Basis mit der
|
|
Arbeits- und Entgeltbestätigung (AP8)."""
|
|
self.ensure_one()
|
|
version = self.version_id
|
|
aj_start = version._l10n_at_efz_arbeitsjahr_start(self.date_from)
|
|
if not aj_start:
|
|
return None
|
|
endtag = max(self.date_to, aj_start)
|
|
kontext = version._l10n_at_efz_phasen(aj_start, endtag,
|
|
stichtag=self.date_from)
|
|
phasen = kontext['phasen']
|
|
if not phasen:
|
|
return None
|
|
halb_wochen = kontext['halb_wochen']
|
|
# Arbeitstage je Phase im Abrechnungszeitraum (Work Entries)
|
|
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', (_KRANK_WET, _AU_WET)),
|
|
])
|
|
voll, halb, oegk = [], [], []
|
|
for eintrag in eintraege:
|
|
phase = phasen.get(eintrag.date)
|
|
if phase == 'voll':
|
|
voll.append(eintrag.date)
|
|
elif phase == 'halb':
|
|
halb.append(eintrag.date)
|
|
elif phase == 'oegk':
|
|
oegk.append(eintrag.date)
|
|
# Episode-Beginn für die fiktive BV (§ 7 Abs 3 BMSVG):
|
|
# frühester zusammenhängender Verhinderungsblock (Lücke = neue
|
|
# Episode), dessen Phase die Halb-/ÖGK-Phase erreicht.
|
|
episode_beginn = None
|
|
for tag in sorted(phasen):
|
|
if phasen[tag] in ('halb', 'oegk'):
|
|
block = tag
|
|
while (block - timedelta(days=1)) in phasen:
|
|
block = block - timedelta(days=1)
|
|
episode_beginn = block
|
|
break
|
|
return {
|
|
'stufe_wochen': version._l10n_at_efz_stufe_am(self.date_from),
|
|
'halb_wochen': halb_wochen,
|
|
'voll_arbeitstage': sorted(set(voll)),
|
|
'halb_arbeitstage': sorted(set(halb)),
|
|
'oegk_arbeitstage': sorted(set(oegk)),
|
|
# Kalendertage der Phasen IM ABRECHNUNGSZEITRAUM — Basis
|
|
# der fiktiven-BV-Aliquote (monatsscharf).
|
|
'oegk_kalendertage': sorted(
|
|
tag for tag in phasen
|
|
if self.date_from <= tag <= self.date_to
|
|
and phasen[tag] == 'oegk'),
|
|
'halb_kalendertage': sorted(
|
|
tag for tag in phasen
|
|
if self.date_from <= tag <= self.date_to
|
|
and phasen[tag] == 'halb'),
|
|
'episode_beginn': episode_beginn,
|
|
}
|
|
|
|
def _at_efz_tagesatz(self, result_rules=None):
|
|
"""Voller Tages-Entgeltsatz des Monats (Entgeltausfalls-
|
|
prinzip): laufende Monatsbasis ohne die eigenen
|
|
Teilentgelt-Reduktionen ÷ Arbeitstage des Kalendermonats.
|
|
Geteilter Helper von EFZG (TEIL_ENTGELT_HALB,
|
|
KRANKENTGELT_PHASE) und Mutterschutz (MSCH_ENTGELT, AP7)."""
|
|
self.ensure_one()
|
|
basis = self._l10n_at_sv_basis_roh(result_rules)
|
|
for code in ('TEIL_ENTGELT_HALB', 'KRANKENTGELT_PHASE',
|
|
'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']
|
|
arbeitstage = self._at_arbeitstage(self.date_from, self.date_to)
|
|
return basis / max(arbeitstage, 1)
|
|
|
|
def _at_bv_fiktiv_krankengeld(self):
|
|
"""Fiktive BV-Grundlage während des Krankengeldbezugs
|
|
(§ 7 Abs 3 BMSVG, lb-krs-07): 50 % des Entgelts des
|
|
Kalendermonats vor dem Versicherungsfall (SZ ausgenommen),
|
|
aliquot nach Kalendertagen des Bezugs (Halb- + ÖGK-Phase,
|
|
30-Tage-Konvention). Bezug = letzter validierter Lauf vor
|
|
Episode-Beginn; ohne solchen: der laufende Monat (kurze
|
|
Dienstverhältnisse, lb-krs-02)."""
|
|
self.ensure_one()
|
|
efz = self._at_efz_status()
|
|
if not efz or not (efz['halb_kalendertage']
|
|
or efz['oegk_kalendertage']):
|
|
return 0.0
|
|
tage = len(efz['halb_kalendertage']) \
|
|
+ len(efz['oegk_kalendertage'])
|
|
vormonat = None
|
|
if efz['episode_beginn']:
|
|
vormonat = self.env['hr.payslip'].search([
|
|
('employee_id', '=', self.employee_id.id),
|
|
('state', 'in', ('validated', 'paid')),
|
|
('date_to', '<', efz['episode_beginn']),
|
|
], order='date_from desc', limit=1)
|
|
basis_monat = vormonat._l10n_at_sv_basis_roh() if vormonat \
|
|
else self._l10n_at_sv_basis_roh()
|
|
return 0.5 * basis_monat * tage / 30.0 |