# 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' l10n_at_abf_alt_anspruchsart = fields.Selection( [('normal', 'Normalanspruch (§ 23 AngG)'), ('eltern', 'Elternaustritt (§ 23a Abs 3/4 AngG)'), ('tod', 'Tod (§ 23 Abs 6 AngG)'), ('pension', 'Pension/Invalidität (§ 23a Abs 1 AngG)'), ('eingefroren', 'Eingefrorener Altanspruch (§ 47 BMSVG)'), ('manuell', 'Sonstiger nachgewiesener Anspruch')], string='AT Abfertigung Alt — Anspruchsart', copy=False) l10n_at_abf_alt_dienstzeit_monate = fields.Integer( string='AT Abfertigung Alt — Dienstzeitmonate', copy=False) l10n_at_abf_alt_monatsentgelt = fields.Float( string='AT Abfertigung Alt — Monatsentgelt', copy=False, help='Bestätigte arbeitsrechtliche Bemessungsgrundlage inklusive ' 'regelmäßiger Bezugsbestandteile und aliquoter Sonderzahlungen.') l10n_at_abf_alt_laufender_arbeitslohn = fields.Float( string='AT Abfertigung Alt — laufender Arbeitslohn', copy=False, help='Laufender Monatsarbeitslohn für den steuerlichen ' 'Vervielfacher nach § 67 Abs 3 EStG.') l10n_at_abf_alt_monatslohnsteuer = fields.Float( string='AT Abfertigung Alt — tarifmäßige Monatslohnsteuer', copy=False, help='Auf den maßgeblichen laufenden Monatsarbeitslohn entfallende ' 'tarifmäßige Lohnsteuer nach den persönlichen Verhältnissen.') l10n_at_abf_alt_mehrfach = fields.Float( string='AT Abfertigung Alt — Anspruchsfaktor', copy=False) l10n_at_abf_alt_nachweis = fields.Text( string='AT Abfertigung Alt — Nachweis/Begründung', copy=False) # ------------------------------------------------------------------ # 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 # ------------------------------------------------------------------ # Abfertigung Alt — §§ 23/23a AngG, § 67 Abs 3 EStG # ------------------------------------------------------------------ def _at_abf_alt_staffel(self, dienstzeit_monate=None): """Arbeitsrechtlicher Grundfaktor nach § 23 Abs 1 AngG.""" self.ensure_one() monate = dienstzeit_monate if monate is None: monate = self.l10n_at_abf_alt_dienstzeit_monate staffel = self._l10n_at_param('at_endabrechnung').get( 'abfertigung_alt_staffel', []) mehrfach = 0.0 for grenze, faktor in staffel: if monate >= int(grenze): mehrfach = float(faktor) return mehrfach def _at_abf_alt_mehrfach_vorschlag(self, dienstzeit_monate=None, anspruchsart=None): """§-23-Faktor mit Halb-/Deckelregeln für Elternaustritt/Tod.""" self.ensure_one() art = anspruchsart or self.l10n_at_abf_alt_anspruchsart monate = dienstzeit_monate if monate is None: monate = self.l10n_at_abf_alt_dienstzeit_monate mehrfach = self._at_abf_alt_staffel(monate) if art == 'eltern': if monate < 60: return 0.0 return min(mehrfach / 2.0, 3.0) if mehrfach else 0.0 if art == 'tod': return mehrfach / 2.0 return mehrfach def _at_abf_alt_betrag(self, result_rules=None): """Gesetzliche Alt-Abfertigung als eigene Bezugszeile.""" self.ensure_one() return self._at_input_amount('ATP_ABF_ALT') def _at_abf_alt_steuer_6(self, result_rules=None): self.ensure_one() satz = float(self._l10n_at_param( 'at_endabrechnung')['abfertigung_steuersatz']) return max(self._at_abf_alt_betrag(result_rules), 0.0) * satz / 100.0 def _at_abf_alt_steuer_vervielfachung(self, result_rules=None): """§ 67 Abs 3: Monats-Tarif-LSt × steuerlicher Vervielfacher. Der steuerliche Vervielfacher ist Abfertigungsbetrag dividiert durch den laufenden Arbeitslohn. Er kann vom arbeitsrechtlichen Staffel-Faktor abweichen, weil die Abfertigungsbasis etwa aliquote Sonderzahlungen enthält (lb-end-01, RF 21 Dienstjahre: 10,5 statt 9). """ self.ensure_one() betrag = self._at_abf_alt_betrag(result_rules) laufend = self.l10n_at_abf_alt_laufender_arbeitslohn if betrag <= 0 or laufend <= 0: return self._at_abf_alt_steuer_6(result_rules) vervielfacher = betrag / laufend return self.l10n_at_abf_alt_monatslohnsteuer * vervielfacher def _at_abf_alt_lohnsteuer(self, result_rules=None): """Zwingend günstigere Steuer aus Vervielfachung und 6 %.""" self.ensure_one() if self._at_abf_alt_betrag(result_rules) <= 0: return 0.0 return min(self._at_abf_alt_steuer_vervielfachung(result_rules), self._at_abf_alt_steuer_6(result_rules)) # ------------------------------------------------------------------ # 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_bemessung in ('laufend', 'sonstig') and 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