Files
odoo-at-payroll/addons/l10n_at_hr_payroll_private/models/payslip_private.py
T
fegger 04099fef39 [IMP] l10n_at_hr_payroll_private: GP4 SV/DB-Anbindung (SVDN_SZ-Nachtrag, DG-Regelblock, FLAF/KommSt/DZ)
Anbindung der privatwirtschaftlichen Structure an den GP4-Kern nach
dem bewährten GP3-Muster (dünne 1-Zeilen-Regeln; Logik und Werte
verbleiben im Kern — hr.salary.rule.struct_id ist required, geteilte
Kern-Regeln sind mechanisch unmöglich).

SVDN_SZ-Nachtrag (§ 67 Abs 12): Die SV-DN auf sonstigen Bezügen war in
GP3 nur als Bemessungsbestandteil der §-67-Lohnsteuer wirksam, fehlte
aber als eigene Abzugszeile — das Netto in §-67-Monaten war um den
SV-DN(SZ) zu hoch (RF-L1: 290,79 € nach §-2a-Korrektur). Die Erwartungen
in test_01/test_20 wurden entsprechend korrigiert.

DG-Block (Kategorie ATP_DG, ohne Nettoeffekt): SVDG_KV/PV/AV/UV/WF/IESG
laufend, SVDG_SZ_* auf Sonderzahlungen, SVDG_UV_GFG (§-53a-Pauschale
geringfügig Beschäftigter) sowie die Lohnnebenkosten FLAFDB (§ 41
FLAG), KOMMST (§ 9 KommStG) und DZ (§§ 122/126 WKG, nur bei Company-
Flag l10n_at_wk_mitglied; Satz je Betriebsstätten-Bundesland).

Gemeinsame LNK-Basis in payslip_private.py (_at_lnk_basis): monatlich
gezahlte Arbeitslöhne nach Zufluss, ohne §-67-Abs-3/6-Bezüge (Kern-Flag
l10n_at_s67_abs), lit-f-Befreiung nur für FLAF und DZ (KommSt ohne),
Freibetragsstaffel 1.095/1.460 €.

Rechenfälle docs/rechenfaelle/sozialversicherung.md (RF-SV-1 bis -7)
und §-2a-Korrekturvermerk in lohnsteuer.md. Tests: test_sv_private
(8 Fälle); gesamt 74/74 grün auf odoo_dev.
2026-09-11 23:22:13 +02:00

412 lines
19 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 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). '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))
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 = 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,
}