ee94f4664f
Pay the untaken vacation, dismissal compensation, time balances and open advances from one exit run. The vacation replacement splits natively into a running and a special-payment component per § 67 Abs 8 lit d, only the running part extends the mandatory insurance under § 11 Abs 2 ASVG with sequential windows (dismissal compensation first) and daily-value ceilings. Dismissal compensation follows the one-fifth rule of § 67 Abs 8 lit b without raising the annual sixth, stays fully social-insurable by allocation over the notice period and fully subject to payroll taxes. Advances are offset against net pay only (§ 293 Abs 3 EO) and time credits carry the 50 % statutory surcharge without § 68 relief. Verified against the original statute texts in .ris/; § 67 Abs 6 lump-sum payments are deliberately deferred to AP15.
315 lines
15 KiB
Python
315 lines
15 KiB
Python
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||
"""AP14-A: Endabrechnungs-Zyklus — Urlaubsersatzleistung (UEL),
|
||
Abgangsentschädigung (§ 67 Abs 8 lit b), Aufrechnung offener Vorschüsse
|
||
und Zeitguthaben/-schulden (§ 19e AZG).
|
||
|
||
Am Originalwortlaut verifiziert (.ris/, Stand 2026-09-14):
|
||
- § 11 Abs 2 ASVG: die Pflichtversicherung besteht für die Zeit des
|
||
Bezuges einer Ersatzleistung für Urlaubsentgelt (und einer
|
||
Kündigungsentschädigung) weiter; gebühren beide, ist zur Bestimmung
|
||
des Zeitraumes ZUERST die Kündigungsentschädigung und im Anschluss
|
||
die Ersatzleistung heranzuziehen (sequentielle Verlängerungsfenster).
|
||
Die 6-WT/5-AT-Tage-Umrechnung ist Verwaltungspraxis (lb-url-15),
|
||
nicht Gesetzeswortlaut.
|
||
- § 67 Abs 8 lit d EStG: UEL „soweit sie laufenden Arbeitslohn
|
||
betreffen, als laufender Arbeitslohn, soweit sie sonstige Bezüge
|
||
betreffen, als sonstiger Bezug im Kalendermonat der Zahlung" — die
|
||
Zweikomponenten-Abbildung läuft nativ über l10n_at_bemessung.
|
||
- § 67 Abs 8 lit b EStG (Kündigungs-/Abgangsentschädigung): nach
|
||
Abzug der §-62-Beiträge ist ein Fünftel steuerfrei zu belassen,
|
||
höchstens ein Fünftel des Neunfachen der monatlichen HBG; der Rest
|
||
läuft tarifisch im Zuflussmonat (Abs 10), Abs 2 (Sechstel) ist
|
||
NICHT anzuwenden. LNK-pflichtig (FLAG § 41 Abs 4 nennt nur
|
||
§ 67 Abs 3 und 6) und SV-pflichtig (keine §-49-Abs-3-Z-7-Befreiung)
|
||
mit Umlegung auf den Kündigungsfrist-Zeitraum.
|
||
- § 19e Abs 2 AZG: Guthaben an Normalarbeitszeit + 50 % Zuschlag
|
||
(Entfall bei unberechtigtem vorzeitigem Austritt; KV kann
|
||
abweichend regeln).
|
||
"""
|
||
from datetime import timedelta
|
||
|
||
from odoo import fields, models
|
||
|
||
|
||
class HrSalaryRule(models.Model):
|
||
_inherit = 'hr.salary.rule'
|
||
|
||
l10n_at_sv_verlaengerung = fields.Selection(
|
||
[('keine', 'Keine SV-Verlängerung (§ 11 Abs 2 ASVG)'),
|
||
('urlaubsersatz', 'UEL-Tage-Schema (Urlaubsersatzleistung)'),
|
||
('abgang', 'Kündigungsfrist (Abgangsentschädigung)')],
|
||
string='AT SV-Verlängerung', default='keine',
|
||
help='Verteilt die laufende SV-Basis dieser Regel auf die '
|
||
'Pflichtversicherungs-Verlängerungsmonate nach § 11 Abs 2 '
|
||
'ASVG statt auf den Abrechnungsmonat: Abgangsentschädigung '
|
||
'zuerst (Kündigungsfrist), UEL anschließend (Tage-Schema '
|
||
'lb-url-15).')
|
||
l10n_at_s67_fuenftel = fields.Boolean(
|
||
string='AT § 67 Abs 8 lit a/b (Fünftelregelung)',
|
||
help='LSt-Pfad der Vergleichs-/Kündigungsentschädigungen: nach '
|
||
'Abzug der §-62-Beiträge ist ein Fünftel steuerfrei '
|
||
'(Deckel: ein Fünftel des Neunfachen der monatlichen HBG), '
|
||
'der Rest läuft tarifisch im Zuflussmonat — ohne '
|
||
'Jahressechstel-Erhöhung (Abs 2 nicht anzuwenden).')
|
||
|
||
|
||
class HrPayslip(models.Model):
|
||
_inherit = 'hr.payslip'
|
||
|
||
# ------------------------------------------------------------------
|
||
# SV-Tage-Umrechnung und Verlängerungsmonate (§ 11 Abs 2 ASVG)
|
||
# ------------------------------------------------------------------
|
||
def _at_end_arbeitsrechtliches_ende(self):
|
||
"""Arbeitsrechtliches Ende = letzter Arbeitstag; das SV-Ende
|
||
(„Ende des Entgeltanspruches") liegt darüber hinaus, wenn ein
|
||
Entgeltanspruch über das Ende hinaus läuft."""
|
||
self.ensure_one()
|
||
return self.version_id.contract_date_end or self.date_to
|
||
|
||
def _at_end_arbeitstage_woche(self):
|
||
"""Arbeitstage pro Woche des Vertragskalenders (Schema-Grundlage
|
||
der SV-Tage-Umrechnung: 5-AT-Woche, 6-WT-Woche, Teilzeitwoche)."""
|
||
self.ensure_one()
|
||
calendar = self.version_id.resource_calendar_id
|
||
days = {int(att.dayofweek) for att in calendar.attendance_ids} \
|
||
if calendar else set()
|
||
return len(days) or 5
|
||
|
||
def _at_end_sv_tage(self, tage):
|
||
"""SV-Tage aus abzurechnenden Urlaubstagen (Verwaltungspraxis
|
||
lb-url-15): je volle Woche à N Arbeitstage werden 7−N
|
||
Kalendertage hinzugerechnet (5-AT-Woche +2, 6-WT-Woche +1,
|
||
2-Tage-Teilzeitwoche +5) — nur ganze Wochenblöcke."""
|
||
self.ensure_one()
|
||
at_woche = self._at_end_arbeitstage_woche()
|
||
blocks = int(tage // at_woche)
|
||
return tage + blocks * (7 - at_woche)
|
||
|
||
def _at_end_verlaengerungs_monate(self, sv_tage, start):
|
||
"""[(Monatsanfang, Tage im Monat)] eines Verlängerungsfensters
|
||
ab dem übergebenen Starttag (§ 11 Abs 2 ASVG)."""
|
||
self.ensure_one()
|
||
ergebnis = {}
|
||
rest = int(sv_tage)
|
||
tag = start
|
||
while rest > 0:
|
||
monatsende = (tag.replace(day=28) + timedelta(days=4)) \
|
||
.replace(day=1) - timedelta(days=1)
|
||
tage_dabei = min((monatsende - tag).days + 1, rest)
|
||
monat = tag.replace(day=1)
|
||
ergebnis[monat] = ergebnis.get(monat, 0) + tage_dabei
|
||
tag = monatsende + timedelta(days=1)
|
||
rest -= tage_dabei
|
||
return sorted(ergebnis.items())
|
||
|
||
def _at_end_rule_verlaengerung(self, rule, result_rules=None):
|
||
"""[(Monatsanfang, Tage, Anteil)] der Regel-Basis nach § 11
|
||
Abs 2: die Abgangsentschädigung belegt ZUERST die (fiktive)
|
||
Kündigungsfrist, die UEL SCHLIESSLICH die Urlaubstage — beide
|
||
Fenster laufen ab dem Tag nach dem arbeitsrechtlichen Ende
|
||
hintereinander (Gesetzeswortlaut: „zunächst die
|
||
Kündigungsentschädigung und im Anschluss die Ersatzleistung")."""
|
||
self.ensure_one()
|
||
start = self._at_end_arbeitsrechtliches_ende() + timedelta(days=1)
|
||
if rule.l10n_at_sv_verlaengerung == 'urlaubsersatz':
|
||
ffrist = self._at_input_amount('ATP_ABGANG_FFRIST_TAGE')
|
||
if ffrist:
|
||
start += timedelta(days=int(ffrist))
|
||
tage = self._at_end_sv_tage(self._at_uel_tage())
|
||
else:
|
||
ffrist = self._at_input_amount('ATP_ABGANG_FFRIST_TAGE')
|
||
tage = int(ffrist) if ffrist else 0
|
||
monate = self._at_end_verlaengerungs_monate(tage, start)
|
||
if not monate:
|
||
return []
|
||
gesamt = sum(tage for _monat, tage in monate)
|
||
return [(monat, tage, tage / float(gesamt))
|
||
for monat, tage in monate]
|
||
|
||
# ------------------------------------------------------------------
|
||
# Eingabe-Helfer
|
||
# ------------------------------------------------------------------
|
||
def _at_input_amount(self, code):
|
||
self.ensure_one()
|
||
linie = self.input_line_ids.filtered(
|
||
lambda i: i.input_type_id.code == code)
|
||
return linie[:1].amount or 0.0
|
||
|
||
# ------------------------------------------------------------------
|
||
# Urlaubsersatzleistung — § 67 Abs 8 lit d, lb-url-15/16
|
||
# ------------------------------------------------------------------
|
||
def _at_uel_tage(self):
|
||
"""Offene Urlaubstage (Arbeitstage; keine Rundung, lb-url-16)."""
|
||
self.ensure_one()
|
||
return self._at_input_amount('ATP_UEL_TAGE')
|
||
|
||
def _at_uel_divisor(self):
|
||
"""Tagewert-Divisor: /26 bei Werktags-, /22 bei 5-AT-Kalender
|
||
(lb-url-16)."""
|
||
self.ensure_one()
|
||
params = self._l10n_at_param('at_endabrechnung')
|
||
if self._at_end_arbeitstage_woche() >= 6:
|
||
return float(params['divisor_werktage'])
|
||
return float(params['divisor_arbeitstage'])
|
||
|
||
def _at_uel_lfd(self):
|
||
"""UEL-laufender Anteil: Tage × Monatsentgelt / Divisor —
|
||
läuft als laufender Bezug durch den Tarif des Austrittsmonats
|
||
und erhöht das Jahressechstel (nativ über bemessung='laufend')."""
|
||
self.ensure_one()
|
||
return self._at_uel_tage() * self._at_kv_gehalt_basis() \
|
||
/ self._at_uel_divisor()
|
||
|
||
def _at_uel_sz_tageswert(self):
|
||
"""SZ-Anteil je Urlaubstag: 2 Sonderzahlungen à Monatsentgelt
|
||
pro Jahr (KV § 11) → je Monat 2 × SZ-Basis/12, als Tageswert
|
||
(auch bei bereits überaliquot bezahlten SZ, lb-url-16)."""
|
||
self.ensure_one()
|
||
sz_monat = 2.0 * self._at_sz_basis(self.date_to) / 12.0
|
||
return sz_monat / self._at_uel_divisor()
|
||
|
||
def _at_uel_sz(self):
|
||
"""UEL-Sonderzahlungsanteil: Tage × SZ-Tageswert."""
|
||
self.ensure_one()
|
||
return self._at_uel_tage() * self._at_uel_sz_tageswert()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Abgangsentschädigung — § 67 Abs 8 lit b (Fünftelregelung)
|
||
# ------------------------------------------------------------------
|
||
def _at_fuenftel_regeln(self):
|
||
self.ensure_one()
|
||
return self.struct_id.rule_ids.filtered(
|
||
lambda rule: rule.l10n_at_s67_fuenftel)
|
||
|
||
def _at_fuenftel_total(self, result_rules=None):
|
||
self.ensure_one()
|
||
total = 0.0
|
||
for rule in self._at_fuenftel_regeln():
|
||
if result_rules is not None:
|
||
if rule.code in result_rules:
|
||
total += result_rules[rule.code]['total']
|
||
else:
|
||
linie = self.line_ids.filtered(lambda l: l.code == rule.code)
|
||
total += sum(linie.mapped('total'))
|
||
return total
|
||
|
||
def _at_fuenftel_tarif_basis(self, result_rules=None):
|
||
"""Tarifischer Teil (4/5) der Fünftel-Bezüge: Bemessung nach
|
||
Abzug der darauf entfallenden §-62-Beiträge (approximiert über
|
||
den effektiven DN-Satz der laufenden Basis; die HBG-Caps der
|
||
Verlängerungsmonate werden im SV-Pfad separat geführt); ein
|
||
Fünftel ist steuerfrei, gedeckelt mit einem Fünftel des
|
||
Neunfachen der monatlichen HBG (lb-end-21: 2026 6.930 × 9/5
|
||
= 12.474)."""
|
||
self.ensure_one()
|
||
total = self._at_fuenftel_total(result_rules)
|
||
if total <= 0:
|
||
return 0.0
|
||
satz = sum(self._l10n_at_sv_satz(
|
||
'dn', komponente,
|
||
staffel_basis=self._l10n_at_sv_basis_roh(result_rules))
|
||
for komponente in ('kv', 'pv', 'av', 'ak', 'wf'))
|
||
bemessung = max(total * (1.0 - satz / 100.0), 0.0)
|
||
deckel = float(self._l10n_at_param('at_sv_hbg_monatlich')) \
|
||
* 9.0 / 5.0
|
||
frei = min(bemessung / 5.0, deckel)
|
||
return bemessung - frei
|
||
|
||
# ------------------------------------------------------------------
|
||
# Engine-Anbindung: Tarif-Teil ohne Sechstel, LNK-Pflicht, SV-Umlegung
|
||
# ------------------------------------------------------------------
|
||
def _l10n_at_bemessung_laufen(self, result_rules=None):
|
||
basis = super()._l10n_at_bemessung_laufen(result_rules)
|
||
# § 67 Abs 8 lit b: 4/5 tarifisch im Zuflussmonat; die Regel
|
||
# selbst ist neutral und erhöht brutto('laufend') nicht → kein
|
||
# Sechstel-Effekt (Abs 2 nicht anzuwenden).
|
||
return basis + self._at_fuenftel_tarif_basis(result_rules)
|
||
|
||
def _at_lnk_basis(self, steuer, result_rules=None):
|
||
"""Upstream-Überschreibung (Muster account_posting_private;
|
||
Vorlage payslip_private._at_lnk_basis — bei Änderungen dort
|
||
gegen diesen Pin neu abgleichen): Kündigungsentschädigungen
|
||
sind Arbeitslöhne iSd § 41 Abs 3 FLAG und unterliegen NICHT
|
||
den Abs-4-Ausschlüssen (lit b nennt nur § 67 Abs 3 und 6) —
|
||
sie sind VOR der Freibetragsstaffel in die LNK-Basis
|
||
aufzunehmen."""
|
||
self.ensure_one()
|
||
basis = (self._l10n_at_brutto('laufend', result_rules)
|
||
+ self._l10n_at_brutto('sonstig', result_rules)
|
||
- self._l10n_at_st_frei_freie_laufen(result_rules))
|
||
for rule in self.struct_id.rule_ids:
|
||
if rule.l10n_at_s67_abs in ('abs3', 'abs6'):
|
||
if result_rules is not None and rule.code in result_rules:
|
||
basis -= result_rules[rule.code]['total']
|
||
elif result_rules is None:
|
||
linie = self.line_ids.filtered(
|
||
lambda l: l.code == rule.code)
|
||
basis -= sum(linie.mapped('total'))
|
||
basis += self._at_fuenftel_total(result_rules)
|
||
basis = max(basis, 0.0)
|
||
if steuer in ('flaf', 'dz'):
|
||
version = self.version_id
|
||
if (version.l10n_at_lnkp_frei_ab
|
||
and self.date_from >= version.l10n_at_lnkp_frei_ab):
|
||
return 0.0
|
||
freibetrag = self.env['hr.rule.parameter']._get_parameter_from_code(
|
||
'at_lnk_freibetrag', self.date_to, raise_if_not_found=False) or {}
|
||
grenze = float(freibetrag.get('grenze', 0.0))
|
||
if basis <= 0.0:
|
||
return 0.0
|
||
if basis <= grenze:
|
||
basis -= float(freibetrag.get('freibetrag', 0.0))
|
||
return max(basis, 0.0)
|
||
|
||
def _l10n_at_sv_basis_laufen_monate(self, result_rules=None):
|
||
"""Upstream-Überschreibung (Muster account_posting_private;
|
||
Vorlage l10n_at_hr_payroll.sozialversicherung — bei Odoo- oder
|
||
Kern-Upgrades gegen diesen Pin neu abgleichen): identischer
|
||
Ablauf wie der Kern, zusätzlich werden Regeln mit AT-SV-
|
||
Verlängerung tageproportional auf die Verlängerungsmonate
|
||
gelegt. Der Standardanteil jedes Monats bleibt auf die volle
|
||
monatliche HBG gedeckelt, der Verlängerungsanteil auf den
|
||
Tageswert (monatliche HBG × Verlängerungstage/30, lb-url-15);
|
||
die Fenster laufen sequenziell (Abgang zuerst, UEL danach)."""
|
||
self.ensure_one()
|
||
gruppen = {}
|
||
verlaengerung = {}
|
||
tage_pro_monat = {}
|
||
for rule in self.struct_id.rule_ids:
|
||
if self._l10n_at_sv_art(rule) != 'laufend':
|
||
continue
|
||
werte = None
|
||
if result_rules is not None:
|
||
if rule.code in result_rules:
|
||
werte = result_rules[rule.code]
|
||
else:
|
||
linie = self.line_ids.filtered(
|
||
lambda l: l.code == rule.code)
|
||
if linie:
|
||
werte = {'total': sum(linie.mapped('total'))}
|
||
if not werte:
|
||
continue
|
||
if rule.l10n_at_sv_verlaengerung != 'keine':
|
||
for monat, tage, anteil in \
|
||
self._at_end_rule_verlaengerung(rule, result_rules):
|
||
verlaengerung[monat] = verlaengerung.get(monat, 0.0) \
|
||
+ werte['total'] * anteil
|
||
tage_pro_monat[monat] = \
|
||
tage_pro_monat.get(monat, 0) + tage
|
||
continue
|
||
monat = (rule.l10n_at_beitragsmonat or self.date_from) \
|
||
.replace(day=1)
|
||
gruppen[monat] = gruppen.get(monat, 0.0) + werte['total']
|
||
standard = self.date_from.replace(day=1)
|
||
if standard in gruppen:
|
||
gruppen[standard] -= self._l10n_at_sv49_freie_laufen(
|
||
result_rules)
|
||
ergebnis = []
|
||
for monat in sorted(set(gruppen) | set(verlaengerung)):
|
||
hbg = float(self._l10n_at_param('at_sv_hbg_monatlich', monat))
|
||
basis = min(max(gruppen.get(monat, 0.0), 0.0), hbg)
|
||
if monat in verlaengerung:
|
||
tageswert = hbg * tage_pro_monat[monat] / 30.0
|
||
basis += min(max(verlaengerung[monat], 0.0), tageswert)
|
||
ergebnis.append((monat, basis))
|
||
return ergebnis |