Files
odoo-at-payroll/addons/l10n_at_hr_payroll_private/models/payslip_private.py
T
fegger 9add58fe83 [IMP] l10n_at_hr_payroll_private: add statutory old-system severance (AP15-A)
Pay the pre-2003 AngG severance from the end-of-employment run as its own neutral benefit line: free of SV (§ 49 Abs 3 Z 7 ASVG) and payroll levies, outside the annual sixth, and collected into the § 291d EO termination pot net of its own wage tax. Taxation follows the binding better-of rule of § 67 Abs 3 EStG: multiple-of-method (tariff wage tax on the running monthly wage × the statutory multiple of the payout) versus the flat 6 % rate, applied mandatorily in favour of the employee. The tariff wage tax is captured as a separately confirmed value instead of deriving it from gross pay through the § 62 engine. The wizard enforces § 23a Abs 3 AngG (parental exit needs five uninterrupted years), blocks BMSVG-new contracts except frozen claims and validates AGRD 02/06 against the claim type. The LNK basis subtracts § 67 Abs 3/6 rules only when they actually run in the tax base, the L16 generator reports LST_ABF in key 260 outside 210/220, and result_rules access in the garnishment paths avoids DefaultDictPayroll materialisation. Verified against the original statute texts in .ris/ (AngG §§ 23/23a, EStG § 67 Abs 3).

Version 19.0.13.0.0 (private), 19.0.9.0.0 (core)
2026-09-15 11:01:55 +02:00

442 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Part of the odoo-at-payroll project. License: LGPL-3.
from datetime import date, timedelta
from odoo import api, fields, models
class HrWorkEntryType(models.Model):
_inherit = 'hr.work.entry.type'
l10n_at_nacht_sv = fields.Boolean(
string='AT Nacht-Sondervergütung (226)',
help='Stunden dieses Typs fallen ins KV-Nachtfenster 226 Uhr '
'und gebühren der Nacht-Sondervergütung nach KV § 6 '
'(zuschlagsunabhängig, kumulativ).')
class HrPayrollStructure(models.Model):
_inherit = 'hr.payroll.structure'
def _l10n_at_entferne_default_net_kopie(self):
"""Odoo kopiert beim Anlegen einer Structure die Regeln des
``hr_payroll.default_structure`` (BASIC/GROSS/NET/ASSIG_
SALARY/…) in ``rule_ids`` (Feld-Default ``_get_default_
rule_ids``). Die kopierte NET-Regel kollidiert ab GP6 mit
unserer Netto-Regel (Code ``NET`` nach Odoo-Konvention —
Zahlungsregistrierung, net_wage und Negativ-Netto-Erkennung
filtern ``code == 'NET'``): zwei Regeln mit Code NET in einer
Structure überschreiben sich im ``result_rules``-Dict und die
Netto-Zeile fällt auf 0. Die Kopie wird daher archiviert —
One2many-Liesungen (rule_ids, Compute-Schleife, NET-Filter)
blenden Archivierte aus, historische Lohnkontolinien behalten
aber ihre Regel-Referenz (struct_id ist NOT NULL, ein Abhängen
ist nicht möglich). Idempotent (Aufruf via <function> in
salary_rules_private.xml bei Install und Update)."""
self.ensure_one()
unsere = self.env.ref(
'l10n_at_hr_payroll_private.rule_netto', False)
kopien = self.rule_ids.filtered(
lambda r: r.code == 'NET' and (not unsere or r != unsere))
kopien.write({'active': False})
class HrVersionPrivate(models.Model):
_inherit = 'hr.version'
# All-in / Überstundenpauschale § 2g AVRAG (Deckungsrechnung GP2):
# zwingender Grundlohnausweis im Dienstzettel; Pauschale gilt bei
# Unterdeckung nur als Akontozahlung (Deckungsprüfbericht).
l10n_at_ue_pauschale_monatlich = fields.Monetary(
string='ÜStd-Pauschale/Monat (All-in § 2g AVRAG)',
currency_field='currency_id',
help='Monatliche Überstundenpauschale lt. Arbeitsvertrag; '
'0 = kein All-in.')
l10n_at_grundlohn_ausweis = fields.Monetary(
string='Grundlohn lt. Dienstzettel (§ 2g AVRAG)',
currency_field='currency_id',
help='Zwingend ausgewiesener Grundlohn (ohne ÜStd-Anteil) im '
'Arbeitsvertrag/Dienstzettel.')
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
# ------------------------------------------------------------------
# Arbeitstage (resource_calendar) — § 17 Abs 2 KV, user convention:
# Werktage des KV werden als Arbeitstage aus dem Ressourcenkalender
# gelesen (MoFr-Kalender: Faktor = AT Dienstspanne / AT Zeitraum;
# für MoSa-Kalender identisch mit ÷26 × Werktage).
# ------------------------------------------------------------------
def _at_arbeitstage(self, von, bis):
"""Arbeitstage (Kalender-Arbeitstage) im Intervall [von, bis]."""
self.ensure_one()
calendar = self.version_id.resource_calendar_id
if not calendar or not von or not bis or von > bis:
return 0
days = {
int(att.dayofweek)
for att in calendar.attendance_ids
}
if not days:
return 0
count = 0
tag = von
while tag <= bis:
if tag.weekday() in days:
count += 1
tag += timedelta(days=1)
return count
def _at_arbeitstage_faktor(self):
"""Prorate-Faktor Ein-/Austritt: Arbeitstage der Beschäftigungs-
spanne (contract_date_*) im Abrechnungszeitraum ÷ Arbeitstage des
vollen Zeitraums."""
self.ensure_one()
version = self.version_id
von = max(self.date_from, version.contract_date_start) \
if version.contract_date_start else self.date_from
bis = min(self.date_to, version.contract_date_end) \
if version.contract_date_end else self.date_to
if von > bis:
return 0.0
total = self._at_arbeitstage(self.date_from, self.date_to)
if not total:
return 1.0
return self._at_arbeitstage(von, bis) / float(total)
# ------------------------------------------------------------------
# KV-Basis / Grundstunden (Teilungsfaktoren § 5 Abs 6 KV)
# ------------------------------------------------------------------
def _at_kv_gehalt_basis(self, datum=None):
"""Volles Monatsgehalt: Überzahlung überschreibt Katalog;
Teilzeit über work_time_rate (Enterprise-Fracht: Bruchteil 0..1,
compute aus Versionskalender ÷ Firmenzalender — D4-verifiziert)."""
self.ensure_one()
version = self.version_id
datum = datum or self.date_to
if version.ueberzahlung:
gehalt = version.ueberzahlung
else:
pos = self.employee_id.kv_gruppe_id._get_gehalt(
self.employee_id.erfahrungsstufe, datum)
gehalt = pos.gehalt if pos else 0.0
return gehalt * (version.work_time_rate or 1.0)
def _at_grundstunde_ue(self):
"""ÜStd-Grundvergütung (und Zuschlagsbasis) = Monatsgehalt ÷
Teilungsfaktor ÜStd (KV § 5 Abs 6: 2203 ÷ 150, 2748 ÷ 143)."""
self.ensure_one()
teiler = self.employee_id.kv_id._wert(
'teilungsfaktor_ue', self.date_to, raise_if_not_found=False)
if not teiler:
return 0.0
return self._at_kv_gehalt_basis() / float(teiler)
def _at_grundstunde_norm(self):
"""Normalarbeitsstunde/Mehrarbeit = Monatsgehalt ÷
Teilungsfaktor Normalstunde (2748: ÷ 167) bzw. Fallback
÷ (4,33 × KV-Wochenstunden) ohne KV-Teilungsfaktor."""
self.ensure_one()
kv = self.employee_id.kv_id
teiler = kv._wert(
'teilungsfaktor_norm', self.date_to, raise_if_not_found=False)
basis = self._at_kv_gehalt_basis()
if teiler:
return basis / float(teiler)
naz = kv._wert('naz', self.date_to, raise_if_not_found=False) or 40
return basis / (4.33 * float(naz))
def _at_nacht_sv_stunden(self):
"""Stunden der Nacht-Sondervergütung: Work-Entry-Types mit
Nacht-Flag (226-Fenster), unabhängig vom Zuschlagsstatus."""
self.ensure_one()
return sum(
(wd.number_of_hours or 0.0)
for wd in self.worked_days_line_ids
if wd.work_entry_type_id.l10n_at_nacht_sv)
# ------------------------------------------------------------------
# 13./14. Bezug (§ 11 KV) — Basis, Fälligkeit, Aliquotierung
# ------------------------------------------------------------------
def _at_sz_basis_datum(self, code):
"""Stichtag der B13-/B14-Basis: 'november' → 30. 11. des
Jahres (2748 B13), sonst Auszahlungsmonat = payslip-Stichtag."""
self.ensure_one()
methode = self.employee_id.kv_id._wert(
code, self.date_to, raise_if_not_found=False) or 'auszahlung'
if methode == 'november':
return date(self.date_to.year, 11, 30)
return self.date_to
def _at_jahres_versionen(self):
"""Versionen des Mitarbeiters mit Überlappung im Kalenderjahr
der Abrechnung (Mischberechnung Beschäftigungsausmaß, 2203
§ 11 Abs 2)."""
self.ensure_one()
jahr = self.date_to.year
von, bis = date(jahr, 1, 1), date(jahr, 12, 31)
versionen = []
for version in self.env['hr.version'].search([
('employee_id', '=', self.employee_id.id),
]):
v_von = version.date_start or von
v_bis = version.date_end or bis
if max(v_von, von) <= min(v_bis, bis):
versionen.append((version, max(v_von, von), min(v_bis, bis)))
return versionen
def _at_sz_basis(self, datum):
"""B13-/B14-Basis zum Stichtag: einheitliches Beschäftigungs-
ausmaß = aktuelles Monatsgehalt; Änderungen des Ausmaßes im
Kalenderjahr → Kalendertag-gewichtete Mischung über die
Jahres-Versionen (Konvention GP2, s. docs/rechenfaelle/b13b14.md)."""
self.ensure_one()
versionen = self._at_jahres_versionen()
if len(versionen) <= 1:
return self._at_kv_gehalt_basis(datum)
if len({(v.work_time_rate or 100.0) for v, _, _ in versionen}) == 1:
return self._at_kv_gehalt_basis(datum)
# Mischberechnung: Kalendertag-Anteile je Version im Jahr
jahr_von, jahr_bis = date(self.date_to.year, 1, 1), date(self.date_to.year, 12, 31)
tage_jahr = (jahr_bis - jahr_von).days + 1
summe_anteile, summe_gehaelter = 0.0, 0.0
for version, v_von, v_bis in versionen:
anteil = ((v_bis - v_von).days + 1) / float(tage_jahr)
if version.ueberzahlung:
gehalt = version.ueberzahlung
else:
pos = version.kv_gruppe_id._get_gehalt(
version.erfahrungsstufe, v_bis)
gehalt = pos.gehalt if pos else 0.0
summe_anteile += anteil
summe_gehaelter += gehalt * anteil \
* (version.work_time_rate or 1.0)
if not summe_anteile:
return 0.0
return summe_gehaelter / summe_anteile
def _at_sz_dienstzeit_faktor(self):
"""Aliquote Ein-/Austritt (§ 11 Abs 4 KV): Methode je
`sz_aliquote_methode` — 'kalendertage' (Default, user decision:
Gehalt/365 × Kalendertage Dienstzeit) oder 'monate' (volle
Monate ÷ 12, anteilige Monate taggenau)."""
self.ensure_one()
jahr = self.date_to.year
jahr_von, jahr_bis = date(jahr, 1, 1), date(jahr, 12, 31)
methode = self.employee_id.kv_id._wert(
'sz_aliquote_methode', self.date_to,
raise_if_not_found=False) or 'kalendertage'
kt_gesamt, monate_gesamt = 0, 0.0
for version, v_von, v_bis in self._at_jahres_versionen():
kt_gesamt += (v_bis - v_von).days + 1
tag = date(jahr, 1, 1)
while tag <= date(jahr, 12, 31):
if v_von <= tag <= v_bis:
monate_gesamt += 1.0 / ((tag.replace(day=28) + timedelta(days=4)).day)
tag += timedelta(days=1)
if methode == 'monate':
return min(monate_gesamt / 12.0, 1.0)
return min(kt_gesamt / 365.0, 1.0)
def _at_sz_bereits_bezahlt(self, code):
"""Summe bereits bezahlter B13-/B14-Linien im Kalenderjahr
(andere, bestätigte Läufe)."""
self.ensure_one()
jahr = self.date_to.year
linien = self.env['hr.payslip.line'].search([
('employee_id', '=', self.employee_id.id),
('code', '=', code),
('date_from', '>=', date(jahr, 1, 1)),
('date_from', '<=', date(jahr, 12, 31)),
])
linien = linien.filtered(
lambda l: l.slip_id.state not in ('draft', 'cancel')
and l.slip_id.id != self.id)
return sum(linien.mapped('total'))
def _at_urlaubstage_jahr(self):
"""Urlaubs-Work-Entries (is_leave) des Kalenderjahres."""
self.ensure_one()
jahr = self.date_to.year
eintraege = self.env['hr.work.entry'].search([
('version_id', 'in', self.employee_id.version_ids.ids),
('work_entry_type_id.is_leave', '=', True),
])
return sum(
1 for we in eintraege
if we.date.year == jahr)
def _at_b14_faellig(self):
"""B14-Fälligkeit § 11 Abs 3 KV: 'urlaub' → bei Urlaubsantritt
(längster Teil — R1: Auszahlung im erstfälligen Urlaubsmonat,
sofern nicht bereits bezahlt); ohne angetretenen Urlaub im
Dezember (`sz14_no_urlaub_monat`); bei Austritt aliquote
Endabrechnung. Fallback ohne KV-Code: Juni-Abrechnung
(user decision)."""
self.ensure_one()
kv = self.employee_id.kv_id
faelligkeit = kv._wert(
'sz14_faelligkeit', self.date_to, raise_if_not_found=False)
if faelligkeit != 'urlaub':
return self.date_to.month == 6 # Fallback Juni-Abrechnung
if self._at_sz_bereits_bezahlt('B14'):
return False
austritt = (
self.version_id.contract_date_end
and self.date_from <= self.version_id.contract_date_end <= self.date_to)
urlaub_im_zeitraum = any(
wd.work_entry_type_id.is_leave
for wd in self.worked_days_line_ids)
if urlaub_im_zeitraum or austritt:
return True
no_urlaub_monat = int(kv._wert(
'sz14_no_urlaub_monat', self.date_to,
raise_if_not_found=False) or 12)
return (self.date_to.month == no_urlaub_monat
and not self._at_urlaubstage_jahr())
def _at_b13_faellig(self):
"""B13-Fälligkeit: Auszahlmonat je KV (`sz13_monat`) bzw.
bei Austritt aliquote Endabrechnung (§ 11 Abs 4 KV)."""
self.ensure_one()
if self._at_sz_bereits_bezahlt('B13'):
return False
monat = int(self.employee_id.kv_id._wert(
'sz13_monat', self.date_to, raise_if_not_found=False) or 12)
austritt = (
self.version_id.contract_date_end
and self.date_from <= self.version_id.contract_date_end <= self.date_to)
return self.date_to.month == monat or austritt
# ------------------------------------------------------------------
# Urlaubsentgelt — regelmäßige Überstunden (§ 2 Abs 2 GKV;
# 7-von-12-Regel: KV § 5 Abs 11 (2203) / Abs 10 (2748))
# ------------------------------------------------------------------
def _at_urlaub_ue_avg(self):
"""(regelmaessig, durchschnittlicher monatlicher ÜStd-Betrag
der letzten 12 Monate) — nur ÜStd-Regeln, ohne Mehrarbeit.
Achtung: Lohnzettel-Lines tragen Regel-Codes (UE_*), nicht
WET-Codes (ATP_*)."""
self.ensure_one()
codes = ('UE_STD', 'UE_NACHT', 'UE_NACHT_FRUEH',
'UE_SOFEI', 'UE_SOFEI_NACHT')
von = fields.Date.subtract(self.date_from, months=12)
linien = self.env['hr.payslip.line'].search([
('employee_id', '=', self.employee_id.id),
('code', 'in', list(codes)),
('date_from', '>=', von),
]).filtered(lambda l: l.slip_id.state not in ('draft', 'cancel'))
if not linien:
return False, 0.0
monate = {l.slip_id.id for l in linien}
return len(monate) >= 7, sum(l.total for l in linien) / 12.0
# ------------------------------------------------------------------
# Lohnnebenkosten FLAF-DG-Beitrag / KommSt / DZ (GP4; § 41 FLAG,
# § 5+§ 9 KommStG, §§ 122/126 WKG — Werte/Staffeln im Kern)
# ------------------------------------------------------------------
def _at_lnk_basis(self, steuer, result_rules=None):
"""LNK-Bemessungsgrundlage nach Freibetragsstaffel: monatlich
gezahlte Arbeitslöhne (laufend + im Monat gezahlte §-67-Bezüge,
Zuflussprinzip; kein SV-Abzug), ohne §-67-Abs-3/6-Bezüge
(Abfertigung/Abfindung), ohne steuerfreie Sachbezüge
§ 3/§ 26 EStG (Freibeträge — lohnnebenkostenfrei, lb-sac-07/02).
'flaf'/'dz' zusätzlich ohne lit-f-AN (Arbeitslöhne ab Folgemonat
nach Vollendung des 60. Lebensjahres, § 41 Abs 4 lit f FLAG —
die Kommunalsteuer hat KEINE lit-f-Befreiung). Freibetrag:
≤ 1.095 € keine Abgabe, bis 1.460 € → Freibetrag 1.095 €, darüber
volle BG (at_lnk_freibetrag)."""
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 = 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 _at_flaf_db(self, result_rules=None):
"""FLAF-Dienstgeberbeitrag § 41 FLAG (3,7 % ab 2025, 2,7 % ab
2028 — at_flaf_db; BG s. _at_lnk_basis)."""
self.ensure_one()
basis = self._at_lnk_basis('flaf', result_rules)
satz = float(self.env['hr.rule.parameter']._get_parameter_from_code(
'at_flaf_db', self.date_to, raise_if_not_found=False) or 0.0)
return basis * satz / 100.0
def _at_kommst(self, result_rules=None):
"""Kommunalsteuer § 9 KommStG 1993 (3 % — at_kommst_satz; BG
§ 5 Abs 2: ohne §-67-Abs-3/6-Bezüge, ohne lit-f-Befreiung)."""
self.ensure_one()
basis = self._at_lnk_basis('kommst', result_rules)
satz = float(self.env['hr.rule.parameter']._get_parameter_from_code(
'at_kommst_satz', self.date_to, raise_if_not_found=False) or 0.0)
return basis * satz / 100.0
def _at_dz(self, result_rules=None):
"""Zuschlag zum Dienstgeberbeitrag (§§ 122/126 WKG) für WKO-
Mitgliedsbetriebe: Landeskammersatz je Betriebsstätten-Bundesland
inkl. Bundeskammeranteil (at_dz_vektor; ⚠ WKO-Verifikation 8.11);
BG identisch FLAF-DB inkl. lit f (lb-lnk-07)."""
self.ensure_one()
if not self.company_id.l10n_at_wk_mitglied:
return 0.0
basis = self._at_lnk_basis('dz', result_rules)
vektor = self.env['hr.rule.parameter']._get_parameter_from_code(
'at_dz_vektor', self.date_to, raise_if_not_found=False) or {}
satz = float((vektor.get('laender') or {}).get(
self.company_id.l10n_at_bundesland or '', 0.0))
return basis * satz / 100.0
# ------------------------------------------------------------------
# All-in-Deckungsrechnung (§ 2g AVRAG) — minimale GP2-Umsetzung:
# Jahresvergleich geschuldete ÜStd-Vergütung vs. erhaltene Pauschale
# ------------------------------------------------------------------
def _at_allin_deckung(self):
"""Deckungsrechnung für Verträge mit ÜStd-Pauschale:
{'pauschale': x, 'geschuldet': y, 'unterdeckung': z}."""
self.ensure_one()
version = self.version_id
pauschale = version.l10n_at_ue_pauschale_monatlich or 0.0
if not pauschale:
return {'pauschale': 0.0, 'geschuldet': 0.0, 'unterdeckung': 0.0}
jahr = self.date_to.year
codes = ('UE_STD', 'UE_NACHT', 'UE_NACHT_FRUEH',
'UE_SOFEI', 'UE_SOFEI_NACHT')
linien = self.env['hr.payslip.line'].search([
('employee_id', '=', self.employee_id.id),
('code', 'in', list(codes)),
('date_from', '>=', date(jahr, 1, 1)),
('date_from', '<=', date(jahr, 12, 31)),
]).filtered(lambda l: l.slip_id.state not in ('draft', 'cancel'))
geschuldet = sum(linien.mapped('total'))
monate = 12 \
if not version.contract_date_end \
or version.contract_date_end.year > jahr \
else version.contract_date_end.month
erhaltene = pauschale * monate
unterdeckung = max(geschuldet - erhaltene, 0.0)
return {
'pauschale': erhaltene,
'geschuldet': geschuldet,
'unterdeckung': unterdeckung,
}