9b4221fcef
AP3 — Wiener Dienstgeberabgabe (U-Bahn-Steuer, RIS LrW GesNr 20000133 verifiziert): Regel WIEN_DAG (Kopfpauschale 2 Euro je DN und angefangene Woche, Wochenlogik Montag-im-Monat, Befreiungen Lehrling/55+/<=10 WStd/Karenz-Woche), Company-Flag-Verwertung, Parameter at_wien_dag_satz, FI 6663/3560 (Referenz-Rest) AP2d — Buchungsbeleg-Report am Lohnlauf (Konto/Belegtext/Soll/Haben der FI-Buchung, balancierte Summen; test_account_private-Erweiterung) AP9 — Vordienstzeiten -> Einstufung: Stufen-Schwelle vwgj_ab an l10n.at.payroll.kv.gruppe.stufe (aus Labels 'nach N VWGJ'; -1 = nicht gepflegt, ORM liest Integer-NULL als 0), CSV-Seed, _get_stufe_fuer_vwgj/_get_naechste_vwgj, Vertragsfeld l10n_at_vordienstzeit_monate + VWGJ-Vorschlag + Vorrueckungstermin (S 17 Abs 5 Rahmen-KV, Erster des Erreichungsmonats) + Uebernahme- Button (manueller Verwaltungsakt) AP4 — EFZG/Teilentgelt (AngG S 8/EFZG S 2 verifiziert, Gleichlauf 6/8/10/12+4 Wochen; lb-krs-07/08): EFZ-Phasenmaschine models/efzg.py (Kalendertage-Kontingente je Arbeitsjahr, Krank-/AU-Topf getrennt, AU-Urlaubsart ATP_AU), Regeln TEIL_ENTGELT_HALB/-50 % und KRANKENTGELT_PHASE/-100 % je Arbeitstag (Tagesatz aus unreduzierter Basis), fiktive BV-Grundlage S 7 Abs 3 BMSVG im _at_bv_basis_laufen- Hook (50 % Vormonatsentgelt x Bezugs-KT/30), Parameter at_efz_stufen; Krankengeldumlage S 62 ASVG = Falsch-Praemise (kein Baustein); Rechenfaelle docs/rechenfaelle/efzg.md
270 lines
12 KiB
Python
270 lines
12 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 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
|
|
# ------------------------------------------------------------------
|
|
|
|
def _at_efz_leaves(self):
|
|
"""Validierte Krankenstand-/AU-Abwesenheiten des AN als
|
|
(start, ende, ist_au)-Kalenderspannen."""
|
|
self.ensure_one()
|
|
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')),
|
|
])
|
|
return [
|
|
(leave.request_date_from, leave.request_date_to,
|
|
leave.holiday_status_id.work_entry_type_id.code == _AU_WET)
|
|
for leave in leaves
|
|
if leave.request_date_from and leave.request_date_to]
|
|
|
|
def _at_efz_arbeitsjahr_start(self):
|
|
"""Beginn des Arbeitsjahres, das die Abrechnungsperiode
|
|
überwiegend enthält (Eintrittsdatum-Rhythmus, lb-krs-08)."""
|
|
self.ensure_one()
|
|
beginn = self.version_id.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 <= self.date_from < aj_naechstes:
|
|
return aj_start
|
|
jahre += 1
|
|
if jahre > beginn.year + 80:
|
|
return None
|
|
|
|
def _at_efz_status(self):
|
|
"""EFZ-Phasen je Abrechnungsperiode: {'stufe_wochen',
|
|
'halb_wochen', 'voll_arbeitstage', 'halb_arbeitstage',
|
|
'oegk_arbeitstage', 'oegk_kalendertage', 'episode_beginn',
|
|
'info'} — None ohne Krankenstand/AU im Arbeitsjahr.
|
|
|
|
Kalendertage-Maschine: Die Krankheits- und AU-Tage des
|
|
Arbeitsjahres werden in Kalenderreihenfolge den Phasen
|
|
zugeteilt (voll bis Stufen-Kontingent, halb zuzüglich
|
|
4 Wochen, danach ÖGK); AU ausschließlich voll (kein
|
|
Halbsatz, § 8 Abs 2a AngG / § 2 Abs 5 EFZG). Ein
|
|
Stufensprung im laufenden Stand verlängert das Kontingent
|
|
(Stufe je Tag neu bewertet, lb-krs-08)."""
|
|
self.ensure_one()
|
|
version = self.version_id
|
|
aj_start = self._at_efz_arbeitsjahr_start()
|
|
if not aj_start:
|
|
return None
|
|
endtag = max(self.date_to, aj_start)
|
|
krank_tage, au_tage = {}, {}
|
|
episode_start = None
|
|
for von, bis, ist_au in self._at_efz_leaves():
|
|
tag = von
|
|
while tag <= bis:
|
|
if aj_start <= tag <= endtag:
|
|
topf = au_tage if ist_au else krank_tage
|
|
topf[tag] = True
|
|
tag += timedelta(days=1)
|
|
if not krank_tage and not au_tage:
|
|
return None
|
|
params = self.env['hr.rule.parameter']._get_parameter_from_code(
|
|
'at_efz_stufen', self.date_from, 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))
|
|
|
|
def stufe_fuer(dienstjahre, tag_ist_au):
|
|
"""Anspruchs-Wochen am Stichtag (vollendete Jahre)."""
|
|
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
|
|
|
|
beginn = version.contract_date_start
|
|
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'
|
|
# 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
|
|
dienstjahre = (self.date_from.year - beginn.year
|
|
- ((self.date_from.month, self.date_from.day)
|
|
< (beginn.month, beginn.day)))
|
|
return {
|
|
'stufe_wochen': stufe_fuer(dienstjahre, False),
|
|
'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."""
|
|
self.ensure_one()
|
|
basis = self._l10n_at_sv_basis_roh(result_rules)
|
|
for code in ('TEIL_ENTGELT_HALB', 'KRANKENTGELT_PHASE'):
|
|
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 |