Files
odoo-at-payroll/addons/l10n_at_hr_payroll_private/models/efzg.py
T
fegger 9b4221fcef [IMP] l10n_at_hr_payroll_private: Wr. DAG, Buchungsbeleg, VWGJ-Einstufung, EFZG-Teilentgelt mit fiktiver BV (AP2d+AP3+AP9+AP4)
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
2026-09-12 23:17:57 +02:00

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