[IMP] l10n_at_hr_payroll_private: GP5 Abfertigung Neu (BVG-Regel, eigene BV-Grundlage, Anwartschaft/Report/Wizard)

Laufender Beitrag (§ 6 BMSVG): Regel BVG (Kategorie ATP_DG, ohne Nettoeffekt) auf EIGENER, ungecappter BV-Grundlage — §-49-bereinigt, ohne HBG-/Jahresdeckel (§ 6 Abs 5 iVm § 49 ASVG) und ohne §-67-Abs-3/6-Zeilen (§ 49 Abs 3 Z 7 ASVG); bewusst kein Rückgriff auf die SV-SZ-Basis des Kerns (Nutzer-Entscheidung eigene BV-Grundlage; deren Z-7-Ausschluss fehlt — GP4-Beobachtung bleibt offen dokumentiert).

Beitragsfreier erster Monat taggenau: Fenster [Eintritt, Eintritt + 1 Monat), Beitragspflicht ab Monatsjubiläum (15. 06. → 15. 07.; KB-Beispiel lb-vor-03: 4.500/30 × 17 = 2.550 — die ab-Folgetag-Lesart war ein Off-by-one und wurde verworfen); 30-Tage-Aliquotierung über die Lohnsteuertage (Teilmonate ohne Doppelabzug); Monatsende-Klippe (Eintritt 31. 01.): Fenster bis Monatsultimo, Pflicht ab Folgemonatserstem (ABGB-Fristrechnung); AV ≤ 1 Monat zur Gänze beitragsfrei.

Wiederzulassung binnen 12 Monaten → Beitragspflicht ab dem 1. Tag; Vor-AV beliebiger Dauer (auch < 1 Monat, Nutzer-Klarstellung) und schlägt auch die Kurz-AV-Freiheit des laufenden AV (OGH 9 ObA 30/16a; Suche über frühere hr.version mit contract_date_end im 12-Monats-Fenster). Sonderzahlungen werden nicht um das Fenster gekürzt, bleiben frei nur bei Beitragsmonat (voller Datumstag, taggenau) im Fenster. Geringfügig Beschäftigte und Lehrlinge sind erfasst — BVG läuft auch im §-53a-Fall.

Anwartschaft (§ 15: gutgebrachte Beiträge + Erträge, keine Monatsentgelt-Staffel) on-the-fly aus validierten Läufen; Anwartschaftsreport (QWeb auf hr.employee) und Endabrechnungs-Wizard (§§ 14-17: §-14-Abs-2-Verfügungsmatrix, < 36 Beitragsmonate sperrt immer, §-16-Fälligkeit am Ende des zweitfolgenden Monats, 6-Monats-Erklärungsfrist, 5 Verfügungsoptionen) — die Auszahlung erfolgt durch die MV-Kasse, nicht über den Payslip.

Stammdaten: Vertrags-Flag l10n_at_abfertigung_neu (§ 46) + Modell l10n.at.bv.kasse (Leitzahl für eSV/mBGM; Seed der 7 Kassen-Namen noupdate, lb-gsv-11 — Leitzahlen aus Mandats-Unterlagen) + Mitarbeiter-Felder l10n_at_bv_ertraege/l10n_at_bv_monate_extern (Kassen-Auszug).

Bewusst nicht: §-7-BMSVG-Fiktivfälle (dokumentierte Option), Geringfügigen-Jahreszahlung + 2,5 % (GP7), BV-Provider (Option).

Tests: test_bv_private (10 Fälle, RF-BV1-10); gesamt 84/84 grün (frischer -i-Lauf auf Wegwerf-DB). Rechenfälle docs/rechenfaelle/abfertigung.md. 19.0.5.0.0.
This commit is contained in:
2026-09-12 08:22:13 +02:00
parent 3aff46de07
commit 0ea7f54682
16 changed files with 1235 additions and 13 deletions
@@ -1,4 +1,7 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import kollektivvertrag
from . import hr_version_private
from . import payslip_private
from . import payslip_private
from . import bv_kasse
from . import abfertigung_private
from . import bv_report
@@ -0,0 +1,257 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Abfertigung Neu (BMSVG) — GP5: laufender BV-Beitrag (§ 6) mit
EIGENER BV-Grundlage, Anwartschafts-Fortschreibung (§ 15) und
Endabrechnung (§§ 14-17).
Verifizierte Grundlagen (RECHTSQUELLEN-Privat Abschn. 8 ✅; KB
lb-vor-03/lb-vor-06/lb-end-03, Stand 2026-08):
- § 6 Abs 1: DG-Beitrag 1,53 vH des monatlichen Entgelts und der
Sonderzahlungen an die Mitarbeitervorsorgekasse (Abfuhr über den
KV-Träger, Fälligkeit 15. Folgemonat, Baubereich BUAK — GP7).
- § 6 Abs 5 iVm § 49 ASVG: Bemessung ohne GFG und HBG → eigene,
ungecappte BV-Grundlage. Bewusst KEIN Rückgriff auf die SV-SZ-Basis
des Kerns: deren §-49-Abs-3-Z-7-Ausschluss für §-67-Abs-3/6-Bezüge
(Abfertigung/Abfindung) ist dort nicht umgesetzt; die BV-Grundlage
schließt diese Zeilen korrekt aus (Nutzer-Entscheidung 2026-09-11).
- Geltungsbereich § 46/§ 1 Abs 2 Z 1: private Arbeitsverhältnisse ab
1. 1. 2003 (Flag am Vertrag); geringfügig Beschäftigte und Lehrlinge
sind ERFASST — BVG läuft auch ohne SV-Hauptbeiträge.
- Beitragsfreier erster Monat (§ 6 Abs 1), taggenau: Fenster =
[Eintritt, Eintritt + 1 Monat), Beitragspflicht ab Monatsjubiläum
(Eintritt 15. 06. → frei bis 14. 07., Pflicht ab 15. 07.; KB-Beispiel
4.500/30 × 17 = 2.550). Monatsende-Klippe (Eintritt 31. 01. → kein
31. 02.): Fenster bis Monatsultimo, Pflicht ab Folgemonatserstem
(ABGB-Fristrechnung). AV ≤ 1 Monat → zur Gänze beitragsfrei (auch
Urlaubsersatzleistung). Wiederzulassung binnen 12 Monaten →
Beitragspflicht ab dem 1. TAG; Vor-AV beliebiger Dauer (auch
< 1 Monat; OGH 9 ObA 30/16a) — schlägt auch die Kurz-AV-Freiheit
des laufenden AV (Nutzer-Entscheidung 2026-09-11).
- Sonderzahlungen: keine aliquote Kürzung um den beitragsfreien
Monat; beitragsfrei nur, wenn ihr Beitragsmonat ins Fenster fällt.
- §§ 14-17: Anwartschaft = gutgebrachte Beiträge + Erträge (KEINE
Monatsentgelt-Staffel); Verfügungsausschluss bei AN-Kündigung,
verschuldeter Entlassung, unberechtigtem vorzeitigem Austritt oder
weniger als 36 Beitragsmonaten (alle Beitragszeiten zählen, daher
manuelle Vor-Arbeitgeber-Monate am Mitarbeiter); Fälligkeit am Ende
des zweitfolgenden Monats nach Geltendmachung (§ 16); fünf
Verfügungsoptionen, ohne Erklärung binnen 6 Monaten
Weiterveranlagung (§ 17).
"""
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import fields, models
class HrVersion(models.Model):
_inherit = 'hr.version'
l10n_at_abfertigung_neu = fields.Boolean(
string='AT Abfertigung Neu (BMSVG)',
default=True,
groups="hr_payroll.group_hr_payroll_user", tracking=True,
help='Dienstverhältnis unterliegt dem BMSVG (§ 46: AV ab '
'1. 1. 2003; § 1 Abs 2 Z 1: Länder/Gemeinden ausgenommen). '
'Für Alt-Verhältnisse (§§ 23 f AngG) und § 47-Übertritte '
'abwählen — dann läuft keine BVG-Regel. Geringfügig '
'Beschäftigte und Lehrlinge sind erfasst.')
l10n_at_bv_kasse_id = fields.Many2one(
'l10n.at.bv.kasse', string='AT Mitarbeitervorsorgekasse',
index=True, groups="hr_payroll.group_hr_payroll_user",
tracking=True,
help='Gewählte BV-Kasse dieses Dienstverhältnisses (lb-vor-02); '
'Anwartschaftskonto und §-17-Verfügungen laufen über diese '
'Kasse.')
class HrEmployee(models.Model):
_inherit = 'hr.employee'
l10n_at_abfertigung_neu = fields.Boolean(
related="version_id.l10n_at_abfertigung_neu", inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")
l10n_at_bv_kasse_id = fields.Many2one(
related="version_id.l10n_at_bv_kasse_id", inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")
l10n_at_bv_ertraege = fields.Monetary(
string='AT BV-Erträge (Kassen-Auszug)',
currency_field='currency_id',
groups="hr_payroll.group_hr_payroll_user",
help='Veranlagungserträge der Mitarbeitervorsorgekasse laut '
'Kassen-Auszug (§ 15 BMSVG: Auszahlung der Anwartschaft = '
'gutgebrachte Beiträge + Erträge) — manuell pflegen.')
l10n_at_bv_monate_extern = fields.Integer(
string='AT BV-Beitragsmonate Vor-Arbeitgeber',
groups="hr_payroll.group_hr_payroll_user",
help='Beitragsmonate aus Vor-Beschäftigungen (§ 14 Abs 2 BMSVG: '
'alle Beitragszeiten über Arbeitgeber hinweg summierbar — '
'36-Monats-Kriterium) — laut Auszug der MV-Kasse pflegen.')
def _at_bv_anwartschaft(self):
"""Anwartschafts-Fortschreibung (§ 15 BMSVG): gutgebrachte
Beiträge aus validierten Läufen, je Beitragsmonat/Jahr
gruppiert (Refund-Läufe netten über negative Linien; Monate
zählen auch aus negativen Linien — der Monat ist belegt)."""
self.ensure_one()
monate = set()
total = 0.0
jahre = {}
slips = self.env['hr.payslip'].search([
('employee_id', '=', self.id),
('state', 'not in', ('draft', 'cancel')),
])
for line in slips.line_ids.filtered(
lambda l: l.code == 'BVG'):
if not line.total:
continue
monat = (line.l10n_at_beitragsmonat
or line.date_from).replace(day=1)
monate.add(monat)
total += line.total
eintrag = jahre.setdefault(
monat.year, {'monate': set(), 'summe': 0.0})
eintrag['monate'].add(monat)
eintrag['summe'] += line.total
return {
'monate': len(monate),
'beitraege': total,
'jahre': [(jahr, len(w['monate']), w['summe'])
for jahr, w in sorted(jahre.items())],
}
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
# ------------------------------------------------------------------
# Beitragsfreies Erstfenster (§ 6 Abs 1 BMSVG, taggenau)
# ------------------------------------------------------------------
def _at_bv_fenster(self):
"""(pflichtig_ab, kurz_av) des Arbeitsverhältnisses:
pflichtig_ab = erstes beitragspflichtiges Datum (None =
Beitragspflicht ab dem 1. Tag bzw. ohne bewertbare
Vertragsdaten); kurz_av = True → AV ≤ 1 Monat, zur Gänze
beitragsfrei. Reihenfolge: Wiederzulassung schlägt alles,
dann Kurz-AV-Freiheit, dann taggenaues Fenster."""
self.ensure_one()
version = self.version_id
start = version.contract_date_start
if not start:
# Ohne Vertragsdaten ist die Befreiung nicht bewertbar →
# konservativ Vollpflicht.
return None, False
# Wiederzulassung binnen 12 Monaten: Beitragspflicht ab dem
# 1. Tag; Vor-Dienstverhältnis beliebiger Dauer (auch
# < 1 Monat) löst die Ausnahme aus.
prior = self.env['hr.version'].search([
('employee_id', '=', self.employee_id.id),
('id', '!=', version.id),
('contract_date_end', '!=', False),
('contract_date_end', '>=',
start - relativedelta(months=12)),
('contract_date_end', '<', start),
], limit=1)
if prior:
return None, False
# Monatsjubiläum, taggenau; Monatsende-Klippe: existiert der
# Jubiläumstag im Folgemonat nicht (Eintritt 31. 01.), endet
# das Fenster am Monatsultimo, Pflicht ab Folgemonatserstem.
jubilaeum = start + relativedelta(months=1)
if jubilaeum.day < start.day:
jubilaeum += timedelta(days=1)
end = version.contract_date_end
if end and end < jubilaeum:
# AV ≤ 1 Monat → zur Gänze beitragsfrei (lb-vor-03, auch
# eine bloße Urlaubsersatzleistung).
return None, True
return jubilaeum, False
# ------------------------------------------------------------------
# Eigene BV-Grundlage (§ 6 Abs 5 iVm § 49 ASVG — ohne GFG/HBG)
# ------------------------------------------------------------------
def _at_bv_sonst_monate(self, result_rules=None):
"""[(Beitragsmonat, sonstige BV-Basis)]: sonstige Bezüge ohne
§-67-Abs-3/6-Zeilen (Abfertigung/Abfindung sind nach
§ 49 Abs 3 Z 7 ASVG beitragsfrei, damit auch nicht BV-
pflichtig), gruppiert nach Regel-Beitragsmonat mit voller
Tagesgenauigkeit (Anspruchsprinzip, Muster
_l10n_at_sv_basis_laufen_monate) — die Fenster-Prüfung braucht
die Fälligkeit taggenau (SZ fällig 20. 07. ist bei Fenster-
Ende 15. 07. pflichtig, der Monatsanfang wäre es nicht)."""
self.ensure_one()
gruppen = {}
for rule in self.struct_id.rule_ids:
if (rule.l10n_at_bemessung != 'sonstig'
or rule.l10n_at_s67_abs != 'keine'):
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
monat = rule.l10n_at_beitragsmonat or self.date_from
gruppen[monat] = gruppen.get(monat, 0.0) + werte['total']
return sorted(gruppen.items())
def _at_bv_basis_laufen(self, result_rules=None):
"""Laufende BV-Grundlage: §-49-bereinigtes Entgelt ohne
HBG-Cap (§ 6 Abs 5), aliquotiert um das beitragsfreie
Erstfenster — 30-Tage-Monat (KB-Beispiel 4.500/30 × 17 =
2.550); Teilmonate über die Lohnsteuertage, um Doppelabzüge
zu vermeiden (das Entgelt ist für die Teilzeit bereits
aliquotiert)."""
self.ensure_one()
pflichtig_ab, kurz_av = self._at_bv_fenster()
if kurz_av:
return 0.0
basis = self._l10n_at_sv_basis_roh(result_rules)
if pflichtig_ab is None or pflichtig_ab <= self.date_from:
return basis
if pflichtig_ab > self.date_to:
# LZP liegt zur Gänze im beitragsfreien Erstfenster.
return 0.0
ende = min(self.date_to,
self.version_id.contract_date_end or self.date_to)
tage = (ende - pflichtig_ab).days + 1
if tage <= 0:
return 0.0
return basis * tage / max(self._l10n_at_lohnsteuertage(), 1)
def _at_bv_basis_sonst(self, result_rules=None):
"""BV-Grundlage sonstiger Bezüge (Sonderzahlungen & Co.):
ungecappt (kein HBG-/Jahresdeckel, § 6 Abs 5) und ohne
§-67-Abs-3/6-Zeilen; keine aliquote Kürzung um das freie
Fenster — frei bleibt nur, was im Fenster fällig wird
(Beitragsmonat im Fenster, lb-vor-03)."""
self.ensure_one()
pflichtig_ab, kurz_av = self._at_bv_fenster()
if kurz_av:
return 0.0
total = 0.0
for monat, basis in self._at_bv_sonst_monate(result_rules):
if pflichtig_ab is not None and monat < pflichtig_ab:
continue
total += basis
return total
def _at_bv_beitrag(self, result_rules=None):
"""BV-Beitrag (§ 6 Abs 1 BMSVG): 1,53 % DG (at_sv_bv_dg) auf
die eigene Grundlage laufend + sonstig; ATP_DG-Zeile ohne
Nettoeffekt (FI-Zuordnung über hr_payroll_account, GP6).
Läuft bewusst auch für geringfügig Beschäftigte und
Lehrlinge (§ 46, § 1 Abs 2 Z 1; Jahreszahlung + 2,5 %
Zinsenzuschlag der Geringfügigen = Zahlungsmodus GP7/GP8)."""
self.ensure_one()
if not self.version_id.l10n_at_abfertigung_neu:
return 0.0
basis = (self._at_bv_basis_laufen(result_rules)
+ self._at_bv_basis_sonst(result_rules))
satz = float(self._l10n_at_param('at_sv_bv_dg'))
return basis * satz / 100.0
@@ -0,0 +1,20 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Betriebliche Vorsorgekasse (BMSVG): Auswahl je Arbeitsverhältnis
(lb-vor-02 — Voraussetzung der Beitragsabfuhr; die Kasse führt das
Anwartschaftskonto und nimmt die §-17-Verfügungen entgegen). Die
Leitzahl ist eSV-/mBGM-Angabe (GP7) und ist aus den Kassen-Unterlagen
des Mandanten zu übernehmen — nicht aus Vermutung."""
from odoo import fields, models
class L10nAtBvKasse(models.Model):
_name = 'l10n.at.bv.kasse'
_description = 'Betriebliche Vorsorgekasse (BMSVG)'
_order = 'name'
name = fields.Char(required=True)
leitzahl = fields.Char(
string='Leitzahl (eSV/mBGM)',
help='Leitzahl der Mitarbeitervorsorgekasse gemäß DM-ORG/'
'mBGM-Angaben (GP7); aus den Kassen-Unterlagen übernehmen.')
active = fields.Boolean(default=True)
@@ -0,0 +1,20 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""QWeb-Datenlieferant für den BV-Anwartschaftsreport (GP5): die
Anwartschaftswerte werden im Python-Teil aufbereitet (Muster
AbstractModel-Report), der QWeb-Teil bleibt frei von Methodenaufrufen."""
from odoo import models
class L10nAtBvAnwartschaftReport(models.AbstractModel):
_name = 'report.l10n_at_hr_payroll_private.report_bv_anwartschaft'
_description = 'BV-Anwartschaftsreport (Abfertigung Neu, § 15 BMSVG)'
def _get_report_values(self, docids, data=None):
docs = self.env['hr.employee'].browse(docids)
return {
'docs': docs,
'anwartschaft': {
employee.id: employee._at_bv_anwartschaft()
for employee in docs
},
}