[IMP] l10n_at_hr_payroll_private: GP2 Entgelt-Engine (structures, WETs, rules, Rechenfall tests)

This commit is contained in:
2026-09-11 20:53:38 +02:00
parent 80bcf8626b
commit 51ff857414
12 changed files with 1308 additions and 25 deletions
@@ -1,3 +1,4 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import kollektivvertrag
from . import hr_version_private
from . import hr_version_private
from . import payslip_private
@@ -0,0 +1,340 @@
# 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
# ------------------------------------------------------------------
# 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,
}