c3b9d6eb8e
Satzstrukturen PDF-verifiziert aus DM-ORG 42.7.0 (ODL, Pages 493-505; Positionsketten lückenlos): G.10 AEB-Krankengeld v12 (SART 70/71, BEST AK, 111 Felder, Satzlänge 1392) und G.11 AEB-Wochengeld v10 (SART 75/76, BEST AW, 64 Felder, 1048). Die .txt-Extraktion des DM-ORG ist unzuverlässig (Feldversatz). - Neues Modell l10n.at.payroll.aeb (models/aeb.py): Episode (verhinderung_von/bis), KZKU, Storno über storno_id (E.10.2.1: nur Fehler/BMGL-Änderung), REFN/Datei/Prüfprotokoll; Erzeugung auf dem ELDA-Bestands-Framework (Vorlauf/Schluss/Satzlängen- selbstkontrolle) - EFZ-Refaktur: Phasen-Karte nach hr.version verschoben (_l10n_at_efz_arbeitsjahr_start/_l10n_at_efz_phasen/ _l10n_at_efz_stufe_am) — Payslip-Status (AP4) und AEB teilen die Maschine; _at_efz_status-Verhalten durch Tests gepinnt - Fachdaten Krankengeld: AEFZ (Anspruch in Wochen), JAGU=1 (Arbeitsjahr), TAGU=2 (Kalendertage), Teilentgelt TPR/TVO/TBI je Phase, Vorerkrankungen ANV/ANB vor der Episode, VENT (Voll-Phase-Ende), Bezugsblöcke BVO/BBI/BBE + BSUM aus den vollen Monatsläufen vor der Episode, SZKZ datengetrieben - Fachdaten Wochengeld: AVER (Ø-Netto der drei Referenzmonate, § 162 Abs 3 ASVG — AP7-Logik), AVON/ABIS, ANGV=K (kein Fortbezug), AGRD-Validierung nach D.22 (SART 75/76 nur 02/03/04/05/06/10/22/24; unzulässige geleert + Protokoll) - Fund: L10nAtEldaSatz.setze(None) übernimmt die Grundstellung nicht — Felder nur per Leerstring aktiv leeren - 4 Tests: SART 70 mit 76-Tage-Episode (AEFZ 06, TPR1 50, TVO1 13.10.2026, VENT 12.10.2026, Bezugsblock), SART 75 mit AVER/AVON/ABIS, Storno 76 (REFN -ST, Kerndaten des Originals), AGRD-Validierung
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'):
|
|
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 |