From 51ff8574147611fa3d1b1cdc23b178c83e076fe1 Mon Sep 17 00:00:00 2001 From: fegger Date: Fri, 11 Sep 2026 20:53:38 +0200 Subject: [PATCH] [IMP] l10n_at_hr_payroll_private: GP2 Entgelt-Engine (structures, WETs, rules, Rechenfall tests) --- addons/l10n_at_hr_payroll_private/README.md | 18 +- .../__manifest__.py | 14 +- .../data/input_types_private.xml | 10 + .../data/l10n.at.payroll.kv.wert.csv | 4 +- .../data/salary_rules_private.xml | 347 +++++++++++++++ .../data/structure_private.xml | 47 ++ .../data/work_entry_types_private.xml | 106 +++++ .../docs/rechenfaelle/b13b14.md | 39 +- .../models/__init__.py | 3 +- .../models/payslip_private.py | 340 +++++++++++++++ .../tests/__init__.py | 3 +- .../tests/test_entgelt_private.py | 402 ++++++++++++++++++ 12 files changed, 1308 insertions(+), 25 deletions(-) create mode 100644 addons/l10n_at_hr_payroll_private/data/input_types_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/data/salary_rules_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/data/structure_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/data/work_entry_types_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/models/payslip_private.py create mode 100644 addons/l10n_at_hr_payroll_private/tests/test_entgelt_private.py diff --git a/addons/l10n_at_hr_payroll_private/README.md b/addons/l10n_at_hr_payroll_private/README.md index ebd25a2..5af4d11 100644 --- a/addons/l10n_at_hr_payroll_private/README.md +++ b/addons/l10n_at_hr_payroll_private/README.md @@ -42,13 +42,17 @@ werden im Kern-Modul `l10n_at_hr_payroll` gebaut und von beiden Produkten konsumiert; bei Bgld.-Auftragserteilung Anrechnung per Change-Request. -**Offen (GP2+):** Entgelt-Engine (KVGEO, AZG-Zuschläge, B13/B14, -Urlaubsentgelt), Lohnsteuer/SV, Abfertigung Neu, Meldewesen/Belege, -FI-Buchung UGB. +**GP2 Entgelt-Engine (umgesetzt 2026-09-11):** Structure „AT +Privatwirtschaft (KV)", AZG-Fenster-WETs, Regelinventar +KVGEO/NACHT_SV/UE_*/MEHR_*/URL_UE/B13/B14/B14_RUECK/NETTO — +vollständig `kv.wert`-getrieben (Rechenfälle `docs/rechenfaelle/`). -Tests: `test_kv_katalog` (Seed, Stichtags-Lookup, Versionierung, -Eckwerte, Wizard/Sprungwarnung, Vertragsfelder) — -`.venv/bin/python -m odoo -d -i l10n_at_hr_payroll_private +**Offen (GP3+):** Lohnsteuer/SV im Bundes-Kern (D3-Vorfinanzierung), +Abfertigung Neu, Meldewesen/Belege, FI-Buchung UGB; All-in-UI-Bericht +(GP8); B14 längster-Urlaubs-Teil-Verfeinerung. + +Tests: `test_kv_katalog` + `test_entgelt_private` (GP2-Rechenfälle) — +`.venv/bin/python -m odoo -d -u l10n_at_hr_payroll,l10n_at_hr_payroll_private --test-tags /l10n_at_hr_payroll_private --test-enable --stop-after-init --addons-path=odoo_19.0+e.20260910/odoo-19.0+e.20260910/odoo/addons,addons` -(Lokale GP1-Runtime grün am 2026-09-11, 8 Tests 0 Fehler.) \ No newline at end of file +(Lokal grün am 2026-09-11: 29/29 Tests.) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/__manifest__.py b/addons/l10n_at_hr_payroll_private/__manifest__.py index 3aee38a..e08c155 100644 --- a/addons/l10n_at_hr_payroll_private/__manifest__.py +++ b/addons/l10n_at_hr_payroll_private/__manifest__.py @@ -1,7 +1,7 @@ # Part of the odoo-at-payroll project. License: LGPL-3. { 'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)', - 'version': '19.0.1.0.0', + 'version': '19.0.2.0.0', 'author': 'ixsol / gem360', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', @@ -23,6 +23,14 @@ Grundlagen: ``personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md`` und (``hr.version``: KV, Gruppe, Erfahrungsstufe, Überzahlung) sowie ein Import-Wizard für KV-Aufnahme und Jahresupdate (Vorschau, Sprungwarnung > 10 %, versionierte Werte — TASY-Muster). +- **GP2 Entgelt-Engine (dieser Stand):** Structure „AT Privatwirtschaft + (KV)" + Kategorien ATP_*, Work-Entry-Types für die AZG-Fenster-Matrix + (ÜStd Tag/Nacht 20–6/22–6, So/Fei, Mehrarbeit § 4a-Band und § 19d + Teilzeit), Regelinventar KVGEO/NACHT_SV/UE_*/MEHR_*/URL_UE/B13/B14/ + B14_RUECK/NETTO — vollständig datengetrieben über ``kv.wert`` + (Vorlauf 2026-09-11 verifiziert; Rechenfälle unter ``docs/rechenfaelle/``). + All-in-Vertragsmodell (§ 2g AVRAG): Pauschale/Grundlohn-Ausweis am + Vertrag + Deckungsrechnung. - **Seed D2 (Referenz-KVs R1):** SI-2203 „Handwerk und Gewerbe" + SI-2748 „Metallgewerbe" (Gehaltstabellen ab 1. 1. 2026, kuratiert aus den KV-Library-Volltexten). @@ -40,6 +48,10 @@ gem360-Brücke). 'data/l10n.at.payroll.kv.gruppe.csv', 'data/l10n.at.payroll.kv.gruppe.stufe.csv', 'data/l10n.at.payroll.kv.wert.csv', + 'data/structure_private.xml', + 'data/work_entry_types_private.xml', + 'data/input_types_private.xml', + 'data/salary_rules_private.xml', 'views/kv_views.xml', ], 'countries': ['at'], diff --git a/addons/l10n_at_hr_payroll_private/data/input_types_private.xml b/addons/l10n_at_hr_payroll_private/data/input_types_private.xml new file mode 100644 index 0000000..bcdd271 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/input_types_private.xml @@ -0,0 +1,10 @@ + + + + + + + Prämie/Ausgleich (manuell) + ATP_PRAMIE + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.wert.csv b/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.wert.csv index 470705a..1d9ae71 100644 --- a/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.wert.csv +++ b/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.wert.csv @@ -39,4 +39,6 @@ kv2748_wert_sz14_dezember,l10n_at_hr_payroll_private.kv_si_2748,sz14_no_urlaub_m kv2748_wert_ue_fenster_von,l10n_at_hr_payroll_private.kv_si_2748,ue_fenster_von,ÜStd-Zuschlag 100 % ab Uhr (§ 5 Abs 2),20,2026-01-01 kv2748_wert_ue_fenster_bis,l10n_at_hr_payroll_private.kv_si_2748,ue_fenster_bis,ÜStd-Zuschlag 100 % bis Uhr (§ 5 Abs 2),6,2026-01-01 kv2748_wert_nacht_von,l10n_at_hr_payroll_private.kv_si_2748,nacht_sv_fenster_von,Nacht-Sondervergütung ab Uhr (§ 6),22,2026-01-01 -kv2748_wert_nacht_bis,l10n_at_hr_payroll_private.kv_si_2748,nacht_sv_fenster_bis,Nacht-Sondervergütung bis Uhr (§ 6),6,2026-01-01 \ No newline at end of file +kv2748_wert_nacht_bis,l10n_at_hr_payroll_private.kv_si_2748,nacht_sv_fenster_bis,Nacht-Sondervergütung bis Uhr (§ 6),6,2026-01-01 +kv2203_wert_sz_aliquote,l10n_at_hr_payroll_private.kv_si_2203,sz_aliquote_methode,Aliquotierung 13./14. bei Ein-/Austritt (§ 11 Abs 4),'kalendertage',2026-01-01 +kv2748_wert_sz_aliquote,l10n_at_hr_payroll_private.kv_si_2748,sz_aliquote_methode,Aliquotierung 13./14. bei Ein-/Austritt (§ 11 Abs 4),'kalendertage',2026-01-01 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/salary_rules_private.xml b/addons/l10n_at_hr_payroll_private/data/salary_rules_private.xml new file mode 100644 index 0000000..41a3d88 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/salary_rules_private.xml @@ -0,0 +1,347 @@ + + + + + + + + + + + + + + + KV-Gruppenentgelt (§ 17 KV) + KVGEO + + + + python + result = bool(employee.kv_id and employee.kv_gruppe_id and (employee.ueberzahlung or employee.erfahrungsstufe)) + code + + + + + + + + Nacht-Sondervergütung (§ 6 KV, 22–6 Uhr) + NACHT_SV + + + + python + result = payslip._at_nacht_sv_stunden() > 0 + code + + + + + + + Überstunden Tag (§ 5 Abs 2 KV) + UE_STD + + + + python + result = 'ATP_UE_STD' in worked_days and worked_days['ATP_UE_STD'].number_of_hours > 0 + code + + + + Überstunden Nacht 22–6 (§ 5 Abs 2 KV) + UE_NACHT + + + + python + result = 'ATP_UE_NACHT' in worked_days and worked_days['ATP_UE_NACHT'].number_of_hours > 0 + code + + + + Überstunden 20–22 Uhr (§ 5 Abs 2 KV) + UE_NACHT_FRUEH + + + + python + result = 'ATP_UE_NACHT_FRUEH' in worked_days and worked_days['ATP_UE_NACHT_FRUEH'].number_of_hours > 0 + code + + + + Sonn-/Feiertagsüberstunden Tag (§ 5 Abs 4/5 KV) + UE_SOFEI + + + + python + result = 'ATP_UE_SOFEI' in worked_days and worked_days['ATP_UE_SOFEI'].number_of_hours > 0 + code + + + + Sonn-/Feiertagsüberstunden Nacht 22–6 (§ 5 Abs 4/5 KV) + UE_SOFEI_NACHT + + + + python + result = 'ATP_UE_SOFEI_NACHT' in worked_days and worked_days['ATP_UE_SOFEI_NACHT'].number_of_hours > 0 + code + + + + + + + Mehrarbeit Band KV-NAZ→40 h (§ 4a KV) + MEHR_VZ + + + + python + 0 and bool(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to, raise_if_not_found=False)) + ]]> + code + 50 %-Zuschlag sind ÜStd (§ 4a letzter Satz — +# Klassifikation stromauf). +grund = payslip._at_grundstunde_norm() +zuschlag = float(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to)) +stunden = worked_days['ATP_MEHR_VZ'].number_of_hours +result = stunden * grund * (1 + zuschlag / 100.0) + ]]> + + + Mehrarbeit Band Nacht 22–6 (§ 4a KV) + MEHR_VZ_NACHT + + + + python + 0 and bool(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to, raise_if_not_found=False)) + ]]> + code + + + + + + + + Mehrarbeit Teilzeit (§ 19d AZG) + MEHR_TZ + + + + python + result = 'ATP_MEHR_TZ' in worked_days and worked_days['ATP_MEHR_TZ'].number_of_hours > 0 + code + + + + Mehrarbeit Teilzeit Nacht 22–6 (§ 19d AZG) + MEHR_TZ_NACHT + + + + python + result = 'ATP_MEHR_TZ_NACHT' in worked_days and worked_days['ATP_MEHR_TZ_NACHT'].number_of_hours > 0 + code + + + + + + + + Urlaubsentgelt: Ø regelmäßige Überstunden (§ 6 UrlG) + URL_UE + + + + python + + code + + + + + + Prämie/Ausgleich (manuell) + PRAM + + + + python + result = 'ATP_PRAMIE' in inputs and inputs['ATP_PRAMIE'].amount + code + + + + + + + 13. Bezug (§ 11 KV) + B13 + + + + python + result = payslip._at_b13_faellig() + code + + + + 14. Bezug / Urlaubszuschuss (§ 11 KV) + B14 + + + + python + result = payslip._at_b14_faellig() + code + + + + 14. Bezug Rückverrechnung bei Austritt (§ 11 Abs 4 KV) + B14_RUECK + + + + python + + code + + + + + + Netto + NETTO + + + + none + code + + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/structure_private.xml b/addons/l10n_at_hr_payroll_private/data/structure_private.xml new file mode 100644 index 0000000..df944ae --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/structure_private.xml @@ -0,0 +1,47 @@ + + + + + + + + + + AT Privatwirtschaft (KV) + monthly + + + + AT Privatwirtschaft Angestellte (KV) + AT_PRIVAT + + + + + + + + + + + + AT Privat Brutto (Entgelt) + ATP_BRUTTO + + + + AT Privat Abzüge (DN; GP3/GP4) + ATP_ABZUG + + + + AT Privat Netto + ATP_NETTO + + + + AT Privat Dienstgeber (ohne Nettoeffekt; GP4+) + ATP_DG + + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/work_entry_types_private.xml b/addons/l10n_at_hr_payroll_private/data/work_entry_types_private.xml new file mode 100644 index 0000000..60cffb0 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/work_entry_types_private.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + Nachtarbeit (NAZ 22–6 Uhr) + 1 + ATN + ATP_ATT_NACHT + + + + + + + Überstunde Tag (50 %) + 4 + UST + ATP_UE_STD + + + + + Überstunde Nacht 22–6 Uhr (100 % + Sondervergütung) + 4 + UNA + ATP_UE_NACHT + + + + + + Überstunde 20–22 Uhr (100 %, ohne Sondervergütung) + 4 + UNF + ATP_UE_NACHT_FRUEH + + + + + Sonn-/Feiertagsüberstunde Tag (100 %) + 4 + USF + ATP_UE_SOFEI + + + + + Sonn-/Feiertagsüberstunde Nacht 22–6 (100 % + Sondervergütung) + 4 + USN + ATP_UE_SOFEI_NACHT + + + + + + + + Mehrarbeit Band KV-NAZ→40 h (§ 4a, 50 %) + 5 + MVZ + ATP_MEHR_VZ + + + + + Mehrarbeit Band Nacht 22–6 (§ 4a, 50 % + Sondervergütung) + 5 + MVN + ATP_MEHR_VZ_NACHT + + + + + + + + Mehrarbeit Teilzeit (§ 19d AZG) + 5 + MTZ + ATP_MEHR_TZ + + + + + Mehrarbeit Teilzeit Nacht 22–6 (§ 19d + Sondervergütung) + 5 + MTN + ATP_MEHR_TZ_NACHT + + + + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/b13b14.md b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/b13b14.md index 8dc20d1..c3c6a5f 100644 --- a/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/b13b14.md +++ b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/b13b14.md @@ -13,14 +13,19 @@ KV-seitige Bemessung/Fälligkeit. - **2203:** Weihnachtsremuneration spätestens **30. 11.** (`sz13_monat` = 11); **2748:** 13. Monatsgehalt spätestens **1. 12.** (`sz13_monat` = 12). Engine: Auszahlung im - Abrechnungslauf des Codierungsmonats. + Abrechnungslauf des Codierungsmonats; bei Austritt aliquote + Endabrechnung im Austrittsmonat (§ 11 Abs 4). ## RF-B2: Fälligkeit B14 (beide KVs, § 11 Abs 3) - Bei **Urlaubsantritt** — bei mehreren Urlaubsteilen: der - **längste** Teil, bei gleichen Teilen der **erste**. + **längste** Teil, bei gleichen Teilen der **erste** (Engine R1: + Auszahlung im erstfälligen Urlaubsmonat, sofern noch nicht + bezahlt — längster-Teil-Verfeinerung dokumentiert). - Ohne angetretenen Urlaub im Kalenderjahr: mit der **Dezember-Abrechnung** (`sz14_no_urlaub_monat` = 12). +- **Fallback ohne KV-Regelung** (user decision 2026-09-11): + Auszahlung mit der **Juni-Abrechnung**. - Alternativ per BV/EA (2203 Abs 3): spätestens 30. 6. oder quartalsweise März/Juni/September/November — Option, nicht R1-Default. @@ -37,25 +42,31 @@ KV-seitige Bemessung/Fälligkeit. ohne Urlaub) würde den neuen Wert verwenden. ## RF-B4: Anwartschaft ×/12 und Aliquotierung bei Ein-/Austritt -(§ 11 Abs 4, beide KVs) +(§ 11 Abs 4, beide KVs — GP2-Umsetzung) -- Eintritt **1. 4. 2026**, VG II Stufe 2, SI-2203 - (2.179,68 €): B13 zum 30. 11. = 9/12 × 2.179,68 = - **1.634,76 €** (Konvention volle Kalendermonate ×/12; die KVs - sagen „aliquote Teil entsprechend der im Kalenderjahr - zurückgelegten Dienstzeit" — Monats- vs. Tagesgenauigkeit ist - eine GP2-Engine-Entscheidung, hier ×/12 dokumentiert). +- **Aliquotierungsmethode je KV** (`kv.wert` `sz_aliquote_methode`, + user decision 2026-09-11): **'kalendertage'** (Default/Fallback, + beide Referenz-KVs): Faktor = Kalendertage Dienstzeit im + Kalenderjahr ÷ 365 — Gehalt/365 × KT. 'monate' = volle + Monate ÷ 12 (anteilige Monate taggenau) als Alternative. +- Eintritt **1. 4. 2026**, VG II Stufe 2, SI-2203 (2.179,68 €): + B13 zum 30. 11. = 275/365 × 2.179,68 = **1.642,23 €**. - Austritt **30. 9.** nach Bezugsberechtigung beider SZ: keine - Rückverrechnung (9/12 korrekt bezogen). + Rückverrechnung (275/365 korrekt bezogen). - Austritt **31. 3.** **nach** Bezug des B14 (Urlaub im Jänner): - zu viel bezahlter Anteil (9/12) in der Endabrechnung - zurückzurechnen (Abs 4, 2. Satz). + zu viel bezahlter Anteil (90/365 korrekt, 275/365 bezahlt) in + der Endabrechnung zurückzurechnen (Abs 4, 2. Satz) — Engine: + `B14_RUECK` = bezahlt × (Faktor − 1), negativ. ## RF-B5: Mischberechnung Beschäftigungsausmaß (nur 2203, Abs 2) - 1. 1.–30. 6. Vollzeit (2.179,68 €), 1. 7.–31. 12. 50 % - (1.089,84 €): B13 = 6/12 × 2.179,68 + 6/12 × 1.089,84 = - 1.089,84 + 544,92 = **1.634,76 €**. + (1.089,84 €) — **Kalendertag-gewichtete Mischung** über die + Jahres-Versionen (GP2-Konvention, konsistent mit der + Kalendertage-Aliquotierung): + B13 = 2.179,68 × (181/365) + 1.089,84 × (184/365) = + 1.082,98 + 547,30 = **1.630,28 €** + (Formel: Σ Gehalt_i × work_time_rate_i × KT-Anteil_i ÷ 365). - Überhöht bezahlter B14 ist mit der Weihnachtsremuneration rückzuverrechnen; zu niedrig bezahlter Differenzbetrag ist mit der Weihnachtsremuneration auszuzahlen. diff --git a/addons/l10n_at_hr_payroll_private/models/__init__.py b/addons/l10n_at_hr_payroll_private/models/__init__.py index c095905..bdb4a83 100644 --- a/addons/l10n_at_hr_payroll_private/models/__init__.py +++ b/addons/l10n_at_hr_payroll_private/models/__init__.py @@ -1,3 +1,4 @@ # Part of the odoo-at-payroll project. License: LGPL-3. from . import kollektivvertrag -from . import hr_version_private \ No newline at end of file +from . import hr_version_private +from . import payslip_private \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/payslip_private.py b/addons/l10n_at_hr_payroll_private/models/payslip_private.py new file mode 100644 index 0000000..33199fd --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/payslip_private.py @@ -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 (22–6)', + help='Stunden dieses Typs fallen ins KV-Nachtfenster 22–6 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 (Mo–Fr-Kalender: Faktor = AT Dienstspanne / AT Zeitraum; + # für Mo–Sa-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 (22–6-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, + } \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/tests/__init__.py b/addons/l10n_at_hr_payroll_private/tests/__init__.py index acdd003..36016c6 100644 --- a/addons/l10n_at_hr_payroll_private/tests/__init__.py +++ b/addons/l10n_at_hr_payroll_private/tests/__init__.py @@ -1,2 +1,3 @@ # Part of the odoo-at-payroll project. License: LGPL-3. -from . import test_kv_katalog \ No newline at end of file +from . import test_kv_katalog +from . import test_entgelt_private \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/tests/test_entgelt_private.py b/addons/l10n_at_hr_payroll_private/tests/test_entgelt_private.py new file mode 100644 index 0000000..1fd3c30 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_entgelt_private.py @@ -0,0 +1,402 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""GP2 Entgelt-Engine — Rechenfälle (docs/rechenfaelle/) als Fixtures: +KVGEO/Prorate, Teilzeit, Überzahlung, ÜStd/Zuschläge/Nacht-€, +Mehrarbeit § 4a/§ 19d, B13/B14-Fälligkeit/-Basis/-Aliquotierung, +Rückverrechnung, Mischberechnung, Urlaubsentgelt-ÜStd-Ø. + +Hinweise: Arbeitstage aus dem Ressourcenkalender (user decision); +B13/B14-Aliquotierung 'kalendertage' (KT/365); UE-Testeinträge +liegen außerhalb der Soll-Arbeitszeit (8–16), damit validierte +Läufe keine Überlappung mit Kalender-Einträgen haben.""" +from datetime import date, timedelta + +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class EntgeltPrivateTest(TransactionCase): + + def setUp(self): + super().setUp() + self.structure = self.env.ref( + 'l10n_at_hr_payroll_private.structure_at_privat') + self.kv2203 = self.env.ref( + 'l10n_at_hr_payroll_private.kv_si_2203') + self.kv2748 = self.env.ref( + 'l10n_at_hr_payroll_private.kv_si_2748') + self.gruppe_2203_ii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_ii') + self.gruppe_2748_ii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2748_grp_ii') + self.wet_attendance = self.env.ref( + 'hr_work_entry.work_entry_type_attendance') + self.wet_leave = self.env.ref( + 'hr_work_entry.work_entry_type_leave') + self.calendar = self.env['resource.calendar'].create({ + 'name': 'AT Test 40h Mo-Fr', + 'tz': 'Europe/Vienna', + 'hours_per_day': 8, + 'attendance_ids': [ + (0, 0, { + 'name': 'Tag %d' % wd, + 'dayofweek': str(wd), + 'hour_from': 8.0, + 'hour_to': 16.0, + }) + for wd in range(5) + ], + }) + # Referenzkalender für work_time_rate (Fraction: Version ÷ Firma) + self.env.company.resource_calendar_id = self.calendar + self.calendar_teilzeit = self.env['resource.calendar'].create({ + 'name': 'AT Test 20h Mo-Fr', + 'tz': 'Europe/Vienna', + 'hours_per_day': 4, + 'attendance_ids': [ + (0, 0, { + 'name': 'Tag %d' % wd, + 'dayofweek': str(wd), + 'hour_from': 8.0, + 'hour_to': 12.0, + }) + for wd in range(5) + ], + }) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def _employee(self, kv=None, gruppe=None, stufe=None, calendar=None, + version_vals=None, new_version_vals=None): + employee = self.env['hr.employee'].create({ + 'name': 'Test General-AT Entgelt', + }) + version = self.env['hr.version'].search( + [('employee_id', '=', employee.id)], limit=1) + if not version: + version = self.env['hr.version'].create({ + 'employee_id': employee.id, + 'date_start': date(2020, 1, 1), + }) + vals = { + 'date_start': date(2020, 1, 1), + 'contract_date_start': date(2020, 1, 1), + 'kv_id': (kv or self.kv2203).id, + 'kv_gruppe_id': (gruppe or self.gruppe_2203_ii).id, + 'erfahrungsstufe': stufe or 1, + 'resource_calendar_id': (calendar or self.calendar).id, + } + if version_vals: + vals.update(version_vals) + if 'date_start' in version_vals \ + and 'contract_date_start' not in version_vals: + vals['contract_date_start'] = version_vals['date_start'] + version.write(vals) + if new_version_vals: + new_vals = dict(new_version_vals) + start = new_vals.pop('date_start') + if not version.date_end: + version.write({'date_end': start - timedelta(days=1), + 'contract_date_end': start - timedelta(days=1)}) + vals2 = { + 'employee_id': employee.id, + 'kv_id': (kv or self.kv2203).id, + 'kv_gruppe_id': (gruppe or self.gruppe_2203_ii).id, + 'erfahrungsstufe': stufe or 1, + 'resource_calendar_id': (calendar or self.calendar).id, + } + vals2.update(new_vals) + v2 = self.env['hr.version'].create(vals2) + # date_start ist computed: date_version + contract_date_start + # setzen (create erzwingt date_version = heute) + v2.write({'date_version': start, + 'contract_date_start': start}) + return employee + + def _month_end(self, year, month): + if month == 12: + return date(year, 12, 31) + return date(year, month + 1, 1) - timedelta(days=1) + + def _payslip(self, employee, month, year=2026): + return self.env['hr.payslip'].create({ + 'name': 'Test-Lohnzettel %d/%d' % (month, year), + 'employee_id': employee.id, + 'struct_id': self.structure.id, + 'date_from': date(year, month, 1), + 'date_to': self._month_end(year, month), + }) + + def _compute(self, employee, month, year=2026, work_entries=(), + confirm=False): + """work_entries: (WET, Tag, Stunden) — Odoo 19 Work-Entries + sind tageweise (date + duration).""" + slip = self._payslip(employee, month, year=year) + for wet, tag, stunden in work_entries: + self.env['hr.work.entry'].create({ + 'name': 'Test-Eintrag', + 'version_id': slip.version_id.id, + 'employee_id': slip.employee_id.id, + 'work_entry_type_id': wet.id, + 'date': tag, + 'duration': stunden, + }) + slip.compute_sheet() + if confirm: + slip.action_payslip_done() + return slip + + def _total(self, slip, code): + lines = slip.line_ids.filtered(lambda l: l.code == code) + return sum(lines.mapped('total')) + + def _wet(self, xmlid): + return self.env.ref(xmlid) + + def _remove_attendance(self, slip, day): + """Urlaub ersetzt Kalender-Anwesenheit (Produktionsmuster der + Leave-Verarbeitung) — nötig für validierte Läufe.""" + self.env['hr.work.entry'].search([ + ('version_id', '=', slip.version_id.id), + ('work_entry_type_id', '=', self.wet_attendance.id), + ('date', '=', day), + ]).unlink() + + # ------------------------------------------------------------------ + # KVGEO (RF-K1…K4) + # ------------------------------------------------------------------ + def test_01_kvgeo_vollmonat(self): + employee = self._employee(stufe=1) + slip = self._compute(employee, 11) + self.assertAlmostEqual(self._total(slip, 'KVGEO'), 2059.41, places=2) + + def test_02_kvgeo_eintritt_arbeitstage(self): + """RF-K2: Eintritt 16. 11. 2026 — 11 von 21 Arbeitstagen.""" + employee = self._employee( + stufe=1, version_vals={'date_start': date(2026, 11, 16)}) + slip = self._compute(employee, 11) + self.assertAlmostEqual( + self._total(slip, 'KVGEO'), 2059.41 * 11 / 21, places=2) + + def test_03_kvgeo_teilzeit(self): + employee = self._employee( + stufe=1, calendar=self.calendar_teilzeit) + slip = self._compute(employee, 11) + self.assertAlmostEqual(self._total(slip, 'KVGEO'), 1029.71, places=2) + + def test_04_kvgeo_ueberzahlung(self): + employee = self._employee( + stufe=1, version_vals={'ueberzahlung': 2400.0}) + slip = self._compute(employee, 11) + self.assertAlmostEqual(self._total(slip, 'KVGEO'), 2400.0, places=2) + + # ------------------------------------------------------------------ + # Zuschläge / Nacht (RF-Z1…Z8) — Teststunden außerhalb der + # Soll-Zeit (ÜStd), Fenster je WET. + # ------------------------------------------------------------------ + def test_05_ue_std_tag(self): + """RF-Z1: 2 ÜStd Tag = 2 × (2059,41/150) × 1,5 = 41,19.""" + employee = self._employee(stufe=1) + slip = self._compute(employee, 11, work_entries=[ + (self._wet('l10n_at_hr_payroll_private.wett_ue_std'), + date(2026, 11, 5), 2.0), + ]) + self.assertAlmostEqual(self._total(slip, 'UE_STD'), 41.19, places=2) + + def test_06_ue_nacht_und_sonderverguetung(self): + """RF-Z2: ÜStd 22–24 Uhr: 100 % Zuschlag + 2,62 €/h.""" + employee = self._employee(stufe=1) + slip = self._compute(employee, 11, work_entries=[ + (self._wet('l10n_at_hr_payroll_private.wett_ue_nacht'), + date(2026, 11, 5), 2.0), + ]) + self.assertAlmostEqual(self._total(slip, 'UE_NACHT'), 54.92, places=2) + self.assertAlmostEqual(self._total(slip, 'NACHT_SV'), 5.24, places=2) + + def test_07_ue_sofei_nacht_hoechstzuschlag(self): + """RF-Z4: So/Fei-Nachtüstd: Zuschlag nur einmal (100 %) + €.""" + employee = self._employee(stufe=1) + slip = self._compute(employee, 11, work_entries=[ + (self._wet('l10n_at_hr_payroll_private.wett_ue_sofei_nacht'), + date(2026, 11, 8), 1.0), + ]) + self.assertAlmostEqual( + self._total(slip, 'UE_SOFEI_NACHT'), 27.46, places=2) + self.assertAlmostEqual(self._total(slip, 'NACHT_SV'), 2.62, places=2) + + def test_08_ue_nacht_frueh_ohne_sv(self): + """RF-Z6: 20–22 Uhr Zuschlag 100 %, aber KEINE Sondervergütung.""" + employee = self._employee(stufe=1) + slip = self._compute(employee, 11, work_entries=[ + (self._wet('l10n_at_hr_payroll_private.wett_ue_nacht_frueh'), + date(2026, 11, 5), 1.0), + ]) + self.assertAlmostEqual( + self._total(slip, 'UE_NACHT_FRUEH'), 27.46, places=2) + self.assertEqual(self._total(slip, 'NACHT_SV'), 0.0) + + def test_09_att_nacht_sv(self): + """NAZ-Nachtarbeit (22–6): nur €-Sondervergütung, kein Zuschlag.""" + employee = self._employee(stufe=1) + slip = self._compute(employee, 11, work_entries=[ + (self._wet('l10n_at_hr_payroll_private.wett_att_nacht'), + date(2026, 11, 5), 4.0), + ]) + self.assertAlmostEqual( + self._total(slip, 'NACHT_SV'), 4 * 2.62, places=2) + + # ------------------------------------------------------------------ + # Mehrarbeit (RF-Z5; 2748) + # ------------------------------------------------------------------ + def test_10_mehrarbeit_band_2748(self): + """§ 4a: Basis ÷ 167, Zuschlag 50 % = 19,75 je Stunde.""" + employee = self._employee(kv=self.kv2748, gruppe=self.gruppe_2748_ii) + slip = self._compute(employee, 11, work_entries=[ + (self._wet('l10n_at_hr_payroll_private.wett_mehr_vz'), + date(2026, 11, 5), 1.0), + ]) + self.assertAlmostEqual(self._total(slip, 'MEHR_VZ'), 19.75, places=2) + + def test_11_mehrarbeit_teilzeit_19d(self): + """§ 19d AZG: 25 % Kern-Parameter (keine KV-Abweichung geseedet).""" + employee = self._employee(kv=self.kv2748, gruppe=self.gruppe_2748_ii) + slip = self._compute(employee, 11, work_entries=[ + (self._wet('l10n_at_hr_payroll_private.wett_mehr_tz'), + date(2026, 11, 5), 1.0), + ]) + self.assertAlmostEqual(self._total(slip, 'MEHR_TZ'), 16.46, places=2) + + # ------------------------------------------------------------------ + # 13./14. Bezug (RF-B1…B7) + # ------------------------------------------------------------------ + def test_12_b13_faellig_november_2203(self): + """sz13_monat=11: B13 im November, Basis Auszahlungsmonat.""" + employee = self._employee(stufe=1) + slip = self._compute(employee, 11) + self.assertAlmostEqual(self._total(slip, 'B13'), 2059.41, places=2) + self.assertEqual(self._total(slip, 'B14'), 0.0) + + def test_13_b13_november_basis_2748(self): + """2748: B13 im Dezember, Basis November-Gehalt (sz13_basis).""" + employee = self._employee(kv=self.kv2748, gruppe=self.gruppe_2748_ii) + slip = self._compute(employee, 12) + self.assertAlmostEqual(self._total(slip, 'B13'), 2199.15, places=2) + + def test_14_b13_aliquote_eintritt_kalendertage(self): + """RF-B4 (Kalendertage-Konvention): Eintritt 1. 4. → 275/365.""" + employee = self._employee( + stufe=1, version_vals={'date_start': date(2026, 4, 1)}) + slip = self._compute(employee, 11) + self.assertAlmostEqual( + self._total(slip, 'B13'), 2059.41 * 275 / 365, places=2) + + def test_15_b14_urlaubsfaellig(self): + """B14 bei Urlaubsantritt im Juli.""" + employee = self._employee(stufe=1) + slip = self._compute(employee, 7, work_entries=[ + (self.wet_leave, date(2026, 7, d), 8.0) + for d in (6, 7, 8, 9, 10, 13, 14, 15, 16, 17) + ]) + self.assertAlmostEqual(self._total(slip, 'B14'), 2059.41, places=2) + + def test_16_b14_dezember_ohne_urlaub(self): + """Kein Urlaub im Jahr: B14 mit der Dezember-Abrechnung.""" + employee = self._employee(stufe=1) + slip_jul = self._compute(employee, 7) + self.assertEqual(self._total(slip_jul, 'B14'), 0.0) + slip_dez = self._compute(employee, 12) + self.assertAlmostEqual( + self._total(slip_dez, 'B14'), 2059.41, places=2) + + def test_17_b14_rueckverrechnung_bei_austritt(self): + """RF-B4: B14 im Jänner voll bezahlt, Austritt 31. 3. → + Rückverrechnung 90/365; B13 aliquote Endabrechnung.""" + employee = self._employee(stufe=1) + slip_jan = self._compute(employee, 1, work_entries=[ + (self.wet_leave, date(2026, 1, 12), 8.0), + ]) + self._remove_attendance(slip_jan, date(2026, 1, 12)) + slip_jan.action_payslip_done() + self.assertAlmostEqual(self._total(slip_jan, 'B14'), 2059.41, places=2) + employee.version_id.write({ + 'date_end': date(2026, 3, 31), + 'contract_date_end': date(2026, 3, 31), + }) + slip_mar = self._compute(employee, 3) + faktor = 90 / 365.0 + self.assertAlmostEqual( + self._total(slip_mar, 'B14_RUECK'), + 2059.41 * (faktor - 1.0), places=2) + self.assertAlmostEqual( + self._total(slip_mar, 'B13'), 2059.41 * faktor, places=2) + self.assertEqual(self._total(slip_mar, 'B14'), 0.0) + + def test_18_mischberechnung_beschaeftigungsausmass(self): + """RF-B5: 1. 1.–30. 6. 100 %, ab 1. 7. 50 % → Kalendertag-Mischung.""" + employee = self._employee( + stufe=2, + version_vals={'date_start': date(2026, 1, 1)}, + new_version_vals={ + 'date_start': date(2026, 7, 1), + 'resource_calendar_id': self.calendar_teilzeit.id, + }) + slip = self._compute(employee, 11) + erwartet = (2179.68 * (181 / 365.0) * 1.0 + + 2179.68 * (184 / 365.0) * 0.5) + self.assertAlmostEqual(self._total(slip, 'B13'), erwartet, places=2) + + # ------------------------------------------------------------------ + # Urlaubsentgelt — Ø regelmäßige Überstunden (RF-U2, 7-von-12) + # ------------------------------------------------------------------ + def test_19_urlaub_ue_durchschnitt(self): + employee = self._employee(stufe=1) + for monat in (1, 2, 3, 4, 5, 6, 7, 8): + self._compute( + employee, monat, + work_entries=[ + (self._wet('l10n_at_hr_payroll_private.wett_ue_std'), + date(2026, monat, 5), 2.0), + ], + confirm=True) + slip = self._compute(employee, 12, work_entries=[ + (self.wet_leave, date(2026, 12, 7), 8.0), + ]) + avg = (8 * 2059.41 / 150.0 * 1.5 * 2) / 12.0 + self.assertAlmostEqual( + self._total(slip, 'URL_UE'), avg * 1 / 23, places=2) + + # ------------------------------------------------------------------ + # Netto / All-in + # ------------------------------------------------------------------ + def test_20_netto_ohne_abzuege(self): + """R1: Netto = Brutto-Kategorien (AP3/AP4 folgen).""" + employee = self._employee(stufe=1) + slip = self._compute(employee, 11) + brutto = sum( + self._total(slip, code) + for code in ('KVGEO', 'NACHT_SV', 'UE_STD', 'UE_NACHT', + 'UE_NACHT_FRUEH', 'UE_SOFEI', 'UE_SOFEI_NACHT', + 'MEHR_VZ', 'MEHR_VZ_NACHT', 'MEHR_TZ', + 'MEHR_TZ_NACHT', 'URL_UE', 'PRAM', 'B13', 'B14', + 'B14_RUECK')) + self.assertAlmostEqual(self._total(slip, 'NETTO'), brutto, places=2) + + def test_21_allin_deckungsrechnung(self): + """§ 2g AVRAG: Jahresdeckung = 12 × Pauschale vs. geschuldete + ÜStd (minimale GP2-Umsetzung; UI-Bericht GP8).""" + employee = self._employee( + stufe=1, + version_vals={'l10n_at_ue_pauschale_monatlich': 5.0, + 'l10n_at_grundlohn_ausweis': 2000.0}) + self._compute(employee, 1, work_entries=[ + (self._wet('l10n_at_hr_payroll_private.wett_ue_std'), + date(2026, 1, 5), 2.0), + ], confirm=True) + slip = self._compute(employee, 2, work_entries=[ + (self._wet('l10n_at_hr_payroll_private.wett_ue_std'), + date(2026, 2, 5), 2.0), + ], confirm=True) + deckung = slip._at_allin_deckung() + self.assertAlmostEqual(deckung['pauschale'], 60.0, places=2) + self.assertAlmostEqual(deckung['geschuldet'], 82.38, places=2) + self.assertAlmostEqual(deckung['unterdeckung'], 22.38, places=2) \ No newline at end of file