From ee94f4664f92ce0a1031b83b220b51c674534213 Mon Sep 17 00:00:00 2001 From: fegger Date: Mon, 14 Sep 2026 16:08:44 +0200 Subject: [PATCH] [IMP] l10n_at_hr_payroll_private: add end-of-employment payroll cycle (AP14-A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pay the untaken vacation, dismissal compensation, time balances and open advances from one exit run. The vacation replacement splits natively into a running and a special-payment component per § 67 Abs 8 lit d, only the running part extends the mandatory insurance under § 11 Abs 2 ASVG with sequential windows (dismissal compensation first) and daily-value ceilings. Dismissal compensation follows the one-fifth rule of § 67 Abs 8 lit b without raising the annual sixth, stays fully social-insurable by allocation over the notice period and fully subject to payroll taxes. Advances are offset against net pay only (§ 293 Abs 3 EO) and time credits carry the 50 % statutory surcharge without § 68 relief. Verified against the original statute texts in .ris/; § 67 Abs 6 lump-sum payments are deliberately deferred to AP15. --- .agents/MEMORY.md | 56 +++- .../__manifest__.py | 6 +- .../input_types_endabrechnung_private.xml | 34 ++ .../rule_parameters_endabrechnung_private.xml | 17 + .../salary_rules_endabrechnung_private.xml | 132 ++++++++ .../docs/rechenfaelle/endabrechnung.md | 95 ++++++ .../models/__init__.py | 1 + .../models/endabrechnung.py | 315 ++++++++++++++++++ .../security/ir.model.access.csv | 1 + .../tests/__init__.py | 1 + .../tests/test_endabrechnung_private.py | 298 +++++++++++++++++ .../views/endabrechnung_views.xml | 58 ++++ .../wizard/__init__.py | 3 +- .../wizard/endabrechnung.py | 176 ++++++++++ 14 files changed, 1189 insertions(+), 4 deletions(-) create mode 100644 addons/l10n_at_hr_payroll_private/data/input_types_endabrechnung_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/data/rule_parameters_endabrechnung_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/data/salary_rules_endabrechnung_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/docs/rechenfaelle/endabrechnung.md create mode 100644 addons/l10n_at_hr_payroll_private/models/endabrechnung.py create mode 100644 addons/l10n_at_hr_payroll_private/tests/test_endabrechnung_private.py create mode 100644 addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml create mode 100644 addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index bf6f3c8..6f1c010 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -2,9 +2,61 @@ ## Current focus -**AP13-B Pendlerförderung umgesetzt — 127 Tests grün** +**AP14-A Endabrechnungs-Zyklus umgesetzt — 133 Tests grün** (2026-09-14, uncommittet bis Nutzerfreigabe): +- **Rechtskorrektur gegen Originalwortlaut (.ris/, RIS-Gate erledigt):** + § 67 Abs 6 (freiwillige Abfertigung/Abfindung) = 6 %-Bänder + (Z 1 ¼ der letzten-12-Monate-Bezüge, Deckel 9×HBG; Z 2 Dienstzeit- + bänder), NICHT Fünftel → nach AP15 verschoben. Fünftelregelung gilt + § 67 Abs 8 lit b (Kündigungs-/Abgangsentschädigung) — diese sind + SV-PFLICHTIG (Umlegung auf Kündigungsfrist, § 11 Abs 2 ASVG) und + LNK-PFLICHTIG (FLAG § 41 Abs 4 lit b nennt nur Abs 3 und 6). + § 11 Abs 2 ASVG verifiziert: PV-Verlängerung für UEL-Bezug UND + Kündigungsentschädigung, bei beiden ZUERST Kündigungsentschädigung, + dann UEL (sequentielle Fenster); 6-WT/5-AT-Tage-Umrechnung ist + Verwaltungspraxis (lb-url-15), nicht Gesetzeswortlaut. § 67 Abs 8 + lit d (UEL-Zweikomponenten) wortwörtlich bestätigt. § 19e Abs 2 + AZG (+50 % auf NAZ-Guthaben) verifiziert. +- **Umsetzung (Privat 19.0.12.0.0):** `models/endabrechnung.py` mit + Regel-Flags `l10n_at_sv_verlaengerung` (urlaubsersatz/abgang) und + `l10n_at_s67_fuenftel`; UEL-Helfer (Divisor 26/22, SZ-Tageswert + 2×SZ-Basis/12); Upstream-Replicas mit Pin-Kommentar für + `_at_lnk_basis` (Abgang VOR der Freibetragsstaffel) und + `_l10n_at_sv_basis_laufen_monate` (Verlängerungsanteile mit + Tageswert-Deckel HBG×Tage/30, Standardanteile volle Monats-HBG); + `_l10n_at_bemessung_laufen` += Fünftel-Tarifteil (4/5, Deckel + 12.474 = 9×HBG/5) ohne Sechstel-Effekt. +- **Regeln:** URL_ERSATZ_LFD (laufend, verlaengerung=urlaubsersatz), + URL_ERSATZ_SZ (sonstig), ABGANG (neutral, sv_art=laufend, + verlaengerung=abgang, fuenftel), ZEIT_GUTHABEN (laufend ×1,5, + KEIN §-68), ZEIT_SCHULDEN (negativ), END_ABRECHNUNG_VORSCHUSS + (neutral/keine — Netto-Abzug § 293 Abs 3 EO). Eingaben + ATP_UEL_TAGE/ABGANG/ABGANG_FFRIST_TAGE/ZEIT_GUTHABEN/ZEIT_SCHULDEN/ + ABRECHNUNG_VORSCHUSS; Parameter at_endabrechnung (Divisoren). +- **Wizard** `l10n.at.payroll.endabrechnung.wizard` (ACL, Views, + Menü seq 42): sammelt UEL-Tage/Abgang/Frist/Zeit/Vorschuss, Vorschau + über flüchtigen new()-Payslip, Button legt Endabrechnungs-Lauf im + Austrittsmonat mit vorbefüllten Eingaben an. Achtung: hr.version- + Strukturfeld heißt `structure_id` (nicht struct_id). +- **Fund beim Testlauf:** Eingabetypen-Datei fehlte zunächst in der + Manifest-Datenliste → NOT-NULL input_type_id; zwei Testassertionen + mussten SV-DN-Differenzen bzw. den KVGEO-Standardanteil im März + berücksichtigen. +- **Tests** test_endabrechnung_private.py (6): UEL-Zweikomponenten + (1.320/220 €, Sechstel +5.280), SV-Tage-Schema (23→31, 12→14, + 6→21) + Monatsverteilung, Abgang-Fünftel (Sechstel unverändert, + LNK +9.000, Umlegung 16/26 Tage), Vorschuss-Netto (Abgaben neutral), + Zeitguthaben ±, Wizard E2E. Suite **133/133** in ap13a_test. +- **Offen AP14-B/AP15:** Vergleich § 67 Abs 8 lit a + freiwillige + Abfertigung § 67 Abs 6 (beides mit Abf-Alt-Günstiger-Maschine, + rollierende 12-Monats-Basis), Prämien-Beendigungs-Rollung, + Meldewesen-Schnittstellen („Ende des Entgeltanspruchs“ in mBGM, + gesonderter Lohnzettel LStR Rz 911a). + +**AP13-B Pendlerförderung umgesetzt — 127 Tests grün** +(2026-09-14, Commit **`9ff977e`** nach Nutzerfreigabe): + - Neu `models/pendler.py`: Modell `l10n.at.payroll.pendler.erklaerung` (effektiv datiert, L-34-/Pendlerrechner-Nachweis als Attachment, pauschale_art klein/gross, fahrten_stufe drittel/zwei_drittel/voll, @@ -39,7 +91,7 @@ (Odoo 19: quantity/price_unit/total_amount) — Test-Helfer korrigiert. - Doku docs/rechenfaelle/pendler.md (RF-PEN-1…5 + offene Punkte: erhöhter VAB 853 €/Zuschlag 804 € als Quer-LSt-Rest, L-34-1-Monats-Meldeprozess). -- Version **19.0.11.0.0**. Commit ausstehend (Nutzerfreigabe). +- Version **19.0.11.0.0**. **AP13-A Reisekosten Inland committed — 127-er Suite grün** (2026-09-14, Commit **`3f307a0`**): diff --git a/addons/l10n_at_hr_payroll_private/__manifest__.py b/addons/l10n_at_hr_payroll_private/__manifest__.py index ea5f358..0d91e92 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.11.0.0', + 'version': '19.0.12.0.0', 'author': 'Florian Egger', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', @@ -104,6 +104,7 @@ gem360-Brücke). 'data/work_entry_types_private.xml', 'data/absence_private.xml', 'data/input_types_private.xml', + 'data/input_types_endabrechnung_private.xml', 'data/salary_rules_private.xml', 'data/salary_rules_lst_private.xml', 'data/salary_rules_efzg_private.xml', @@ -113,6 +114,7 @@ gem360-Brücke). 'data/salary_rules_sb_private.xml', 'data/salary_rules_sb2_private.xml', 'data/salary_rules_reisekosten_private.xml', + 'data/salary_rules_endabrechnung_private.xml', 'data/salary_rules_sv_private.xml', 'data/salary_rules_bvg_private.xml', 'data/salary_rules_bvg_private.xml', @@ -126,6 +128,7 @@ gem360-Brücke). 'data/rule_parameters_sb2_private.xml', 'data/rule_parameters_reisekosten_private.xml', 'data/rule_parameters_pendler_private.xml', + 'data/rule_parameters_endabrechnung_private.xml', 'data/bv_kasse_seed.xml', 'views/kv_views.xml', 'views/bv_views.xml', @@ -137,6 +140,7 @@ gem360-Brücke). 'views/sachbezuege_views.xml', 'views/reisekosten_views.xml', 'views/pendler_views.xml', + 'views/endabrechnung_views.xml', 'report/gp8_reports.xml', 'report/buchungsbeleg_report.xml', 'report/bv_report.xml', diff --git a/addons/l10n_at_hr_payroll_private/data/input_types_endabrechnung_private.xml b/addons/l10n_at_hr_payroll_private/data/input_types_endabrechnung_private.xml new file mode 100644 index 0000000..7b1c7c9 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/input_types_endabrechnung_private.xml @@ -0,0 +1,34 @@ + + + + + Urlaubsersatzleistung — offene Urlaubstage + ATP_UEL_TAGE + + + + Abgangsentschädigung (Brutto) + ATP_ABGANG + + + Abgangsentschädigung — Kündigungsfrist (Kalendertage) + ATP_ABGANG_FFRIST_TAGE + + + + Zeitguthaben Normalarbeitszeit (Stunden) + ATP_ZEIT_GUTHABEN + + + + Zeitschulden (Stunden) + ATP_ZEIT_SCHULDEN + + + + Aufrechnung offener Vorschuss (Netto) + ATP_ABRECHNUNG_VORSCHUSS + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/rule_parameters_endabrechnung_private.xml b/addons/l10n_at_hr_payroll_private/data/rule_parameters_endabrechnung_private.xml new file mode 100644 index 0000000..5088a8f --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/rule_parameters_endabrechnung_private.xml @@ -0,0 +1,17 @@ + + + + + Endabrechnung — UEL-Tagewert-Divisoren + at_endabrechnung + + + + + 2026-01-01 + {'divisor_werktage': 26.0, 'divisor_arbeitstage': 22.0} + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/salary_rules_endabrechnung_private.xml b/addons/l10n_at_hr_payroll_private/data/salary_rules_endabrechnung_private.xml new file mode 100644 index 0000000..2bc5040 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/salary_rules_endabrechnung_private.xml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + Urlaubsersatzleistung — laufender Anteil (§ 67 Abs 8 lit d) + URL_ERSATZ_LFD + + + + python + result = 'ATP_UEL_TAGE' in inputs and inputs['ATP_UEL_TAGE'].amount + laufend + urlaubsersatz + code + + + + + + Urlaubsersatzleistung — Sonderzahlungsanteil (§ 67 Abs 8 lit d) + URL_ERSATZ_SZ + + + + python + result = 'ATP_UEL_TAGE' in inputs and inputs['ATP_UEL_TAGE'].amount + sonstig + code + + + + + + Abgangsentschädigung (§ 67 Abs 8 lit b) + ABGANG + + + + python + result = 'ATP_ABGANG' in inputs and inputs['ATP_ABGANG'].amount + neutral + laufend + + abgang + code + + + + + + Zeitguthaben Normalarbeitszeit + 50 % (§ 19e Abs 2 AZG) + ZEIT_GUTHABEN + + + + python + result = 'ATP_ZEIT_GUTHABEN' in inputs and inputs['ATP_ZEIT_GUTHABEN'].amount + laufend + code + + + + + + Zeitschulden-Abrechnung (negative Zeile) + ZEIT_SCHULDEN + + + + python + result = 'ATP_ZEIT_SCHULDEN' in inputs and inputs['ATP_ZEIT_SCHULDEN'].amount + laufend + code + + + + + + Aufrechnung offener Vorschuss (Netto) + END_ABRECHNUNG_VORSCHUSS + + + + python + result = 'ATP_ABRECHNUNG_VORSCHUSS' in inputs and inputs['ATP_ABRECHNUNG_VORSCHUSS'].amount + neutral + keine + code + + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/endabrechnung.md b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/endabrechnung.md new file mode 100644 index 0000000..1a61b03 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/endabrechnung.md @@ -0,0 +1,95 @@ +# Endabrechnungs-Zyklus — Rechenfälle AP14-A + +Stand: 2026-09. AP14-A bildet die Beendigungs-Abrechnung auf der +privaten Structure ab: Urlaubsersatzleistung (UEL), Abgangsentschädigung, +Aufrechnung offener Vorschüsse und Zeitguthaben/-schulden. Grundlagen +sind am Originalwortlaut verifiziert (`​.ris/`, Stand 2026-09-14): +§ 11 Abs 2 ASVG, § 67 Abs 8 lit b und lit d EStG, § 19e Abs 2 AZG, +§ 41 Abs 4 FLAG. + +## Rechts- und Systemgrenzen + +- **UEL (§ 10 UrlG, § 67 Abs 8 lit d EStG):** Zweikomponenten — der + laufende Anteil läuft als laufender Bezug durch den Tarif des + Austrittsmonats und erhöht das Jahressechstel (nativ über + `bemessung='laufend'`); der Sonderzahlungsanteil läuft als sonstiger + Bezug durch das 6 %-Band (Freibetrag 620 €, Freigrenze 2.615 € im + Kern). Nur der laufende Anteil verlängert die Pflichtversicherung + (§ 11 Abs 2 ASVG). Tagewert-Divisoren 26 (Werktage) / 22 (5-AT); die + SV-Tage-Umrechnung (je volle Woche à N AT + 7−N KT, nur ganze + Blöcke) ist Verwaltungspraxis (lb-url-15), nicht Gesetzeswortlaut. +- **Abgangsentschädigung (§ 67 Abs 8 lit b):** nach Abzug der §-62- + Beiträge ein Fünftel steuerfrei, gedeckelt mit einem Fünftel des + Neunfachen der monatlichen HBG (2026: 6.930 × 9/5 = **12.474 €**); + der Rest läuft tarifisch im Zuflussmonat — Abs 2 (Sechstel) ist nicht + anzuwenden. SV-pflichtig mit Umlegung auf den Kündigungsfrist- + Zeitraum; voll lohnnebenkostenpflichtig (FLAG § 41 Abs 4 nennt nur + § 67 Abs 3 und 6). +- **Sequentielle Verlängerungsfenster:** Gebühren sowohl eine + Kündigungsentschädigung als auch eine UEL, ist zur Bestimmung des + Zeitraumes zunächst die Kündigungsentschädigung und im Anschluss die + Ersatzleistung heranzuziehen (§ 11 Abs 2 ASVG) — die SV-Basen werden + fensterweise auf die Monate verteilt, Standardanteile bleiben auf + die volle monatliche HBG gedeckelt, Verlängerungsanteile auf den + Tageswert (HBG × Tage/30). +- **Aufrechnung offener Vorschüsse (§ 293 Abs 3 EO):** Rückverrechnung + vom Netto nach SV und Lohnsteuer — zulässig auch gegen das + Existenzminimum; keine Abgabenberührung (lb-end-06). +- **Zeitguthaben (§ 19e Abs 2 AZG):** Guthaben an Normalarbeitszeit + + 50 % Zuschlag, bemessen mit der Normalstunde; der Zuschlag ist + KEIN Überstundenzuschlag iSd § 68 EStG (lb-end-10). Entfall bei + unberechtigtem vorzeitigem Austritt (Eingabe weglassen); KV kann + abweichen. + +## RF-END-1 — UEL-Zweikomponenten + +Ende 15. 3., 11 offene AT à 120 € (2.640 € Gehalt / 22): + +- `URL_ERSATZ_LFD` = 1.320 €: tarifisch im März, Sechstel +5.280 € + (annualisiert 60/15); +- `URL_ERSATZ_SZ` = 220 € (2 × 2.640/12 = 440/Monat → 20/Tag): + 6 %-Band; +- SV: 1.320 € auf die Verlängerungsmonate (Tage-Schema), 220 € als + sonstige Beitragsgrundlage gegen Monats- und Jahres-HBGl. + +## RF-END-2 — SV-Tage-Umrechnung (§ 11 Abs 2) + +- 23 AT (5-AT-Woche) → +4×2 KT = **31 SV-Tage**; +- 12 WT (6-WT-Woche) → +2×1 KT = **14**; +- 6 AT (2-Tage-Teilzeitwoche) → +3×5 KT = **21** (lb-url-15-Beispiel). +- Ende 15. 3., 31 SV-Tage → Verlängerung 16. 3.–15. 4.; UEL-lfd-Basis + tageproportional (März 16/31, April 15/31), Tageswert-Deckel je + Verlängerungsmonat. + +## RF-END-3 — Abgangsentschädigung (Fünftelregelung) + +9.000 € Entschädigung, 42 Tage Kündigungsfrist, DN-Satz s: + +- Bemessung 9.000 × (1 − s/100); davon 1/5 steuerfrei (≤ 12.474 €); + 4/5 tarifisch im Zuflussmonat, ohne Sechstelerhöhung; +- SV: Umlegung 16. 3.–26. 4. (Kündigungsfrist zuerst); +- LNK: volle 9.000 € in der Basis (§ 41 Abs 3 FLAG). + +## RF-END-4 — Aufrechnung + +2.000 € offener Vorschuss: `END_ABRECHNUNG_VORSCHUSS` mindert nur das +Netto; LSt-, SV- und LNK-Basen unverändert. + +## RF-END-5 — Zeitguthaben/-schulden + +10 h Normalarbeitszeit-Guthaben × 1,5 + 5 h Zeitschulden × (−1): +beides laufender Bezug, kein §-68-Freibetrag. + +## Offene Punkte (dokumentiert, AP14-B/AP15) + +- **Vergleich (§ 67 Abs 8 lit a)** und **freiwillige Abfertigung/ + Abfindung (§ 67 Abs 6 6 %-Bänder)** → AP15 mit der Abfertigung-Alt- + Günstiger-Maschine (dort auch rollierende 12-Monats-Basis und + Dienstzeit-Nachweise). +- Prämien-Beendigung (§ 16 AngG-Aliquotierung, Rollung) — PRAM/SZ-Regeln + vorhanden; Feinschliff AP14-B. +- Meldewesen-Schnittstellen: „Ende des Entgeltanspruches" in mBGM/ + Abmeldung (GP7), gesonderter Lohnzettel bei Zahlung außerhalb des + Beendigungsmonats (LStR 2002 Rz 911a). +- Der §-62-Abzug des Fünftelpfades ist über den effektiven DN-Satz + approximiert (HBG-Caps der Verlängerungsmonate laufen im SV-Pfad). \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/__init__.py b/addons/l10n_at_hr_payroll_private/models/__init__.py index ace0d05..2e1412f 100644 --- a/addons/l10n_at_hr_payroll_private/models/__init__.py +++ b/addons/l10n_at_hr_payroll_private/models/__init__.py @@ -20,3 +20,4 @@ from . import pfaendung_report from . import sachbezuege_private from . import reisekosten from . import pendler +from . import endabrechnung diff --git a/addons/l10n_at_hr_payroll_private/models/endabrechnung.py b/addons/l10n_at_hr_payroll_private/models/endabrechnung.py new file mode 100644 index 0000000..38c9281 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/endabrechnung.py @@ -0,0 +1,315 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""AP14-A: Endabrechnungs-Zyklus — Urlaubsersatzleistung (UEL), +Abgangsentschädigung (§ 67 Abs 8 lit b), Aufrechnung offener Vorschüsse +und Zeitguthaben/-schulden (§ 19e AZG). + +Am Originalwortlaut verifiziert (.ris/, Stand 2026-09-14): +- § 11 Abs 2 ASVG: die Pflichtversicherung besteht für die Zeit des + Bezuges einer Ersatzleistung für Urlaubsentgelt (und einer + Kündigungsentschädigung) weiter; gebühren beide, ist zur Bestimmung + des Zeitraumes ZUERST die Kündigungsentschädigung und im Anschluss + die Ersatzleistung heranzuziehen (sequentielle Verlängerungsfenster). + Die 6-WT/5-AT-Tage-Umrechnung ist Verwaltungspraxis (lb-url-15), + nicht Gesetzeswortlaut. +- § 67 Abs 8 lit d EStG: UEL „soweit sie laufenden Arbeitslohn + betreffen, als laufender Arbeitslohn, soweit sie sonstige Bezüge + betreffen, als sonstiger Bezug im Kalendermonat der Zahlung" — die + Zweikomponenten-Abbildung läuft nativ über l10n_at_bemessung. +- § 67 Abs 8 lit b EStG (Kündigungs-/Abgangsentschädigung): nach + Abzug der §-62-Beiträge ist ein Fünftel steuerfrei zu belassen, + höchstens ein Fünftel des Neunfachen der monatlichen HBG; der Rest + läuft tarifisch im Zuflussmonat (Abs 10), Abs 2 (Sechstel) ist + NICHT anzuwenden. LNK-pflichtig (FLAG § 41 Abs 4 nennt nur + § 67 Abs 3 und 6) und SV-pflichtig (keine §-49-Abs-3-Z-7-Befreiung) + mit Umlegung auf den Kündigungsfrist-Zeitraum. +- § 19e Abs 2 AZG: Guthaben an Normalarbeitszeit + 50 % Zuschlag + (Entfall bei unberechtigtem vorzeitigem Austritt; KV kann + abweichend regeln). +""" +from datetime import timedelta + +from odoo import fields, models + + +class HrSalaryRule(models.Model): + _inherit = 'hr.salary.rule' + + l10n_at_sv_verlaengerung = fields.Selection( + [('keine', 'Keine SV-Verlängerung (§ 11 Abs 2 ASVG)'), + ('urlaubsersatz', 'UEL-Tage-Schema (Urlaubsersatzleistung)'), + ('abgang', 'Kündigungsfrist (Abgangsentschädigung)')], + string='AT SV-Verlängerung', default='keine', + help='Verteilt die laufende SV-Basis dieser Regel auf die ' + 'Pflichtversicherungs-Verlängerungsmonate nach § 11 Abs 2 ' + 'ASVG statt auf den Abrechnungsmonat: Abgangsentschädigung ' + 'zuerst (Kündigungsfrist), UEL anschließend (Tage-Schema ' + 'lb-url-15).') + l10n_at_s67_fuenftel = fields.Boolean( + string='AT § 67 Abs 8 lit a/b (Fünftelregelung)', + help='LSt-Pfad der Vergleichs-/Kündigungsentschädigungen: nach ' + 'Abzug der §-62-Beiträge ist ein Fünftel steuerfrei ' + '(Deckel: ein Fünftel des Neunfachen der monatlichen HBG), ' + 'der Rest läuft tarifisch im Zuflussmonat — ohne ' + 'Jahressechstel-Erhöhung (Abs 2 nicht anzuwenden).') + + +class HrPayslip(models.Model): + _inherit = 'hr.payslip' + + # ------------------------------------------------------------------ + # SV-Tage-Umrechnung und Verlängerungsmonate (§ 11 Abs 2 ASVG) + # ------------------------------------------------------------------ + def _at_end_arbeitsrechtliches_ende(self): + """Arbeitsrechtliches Ende = letzter Arbeitstag; das SV-Ende + („Ende des Entgeltanspruches") liegt darüber hinaus, wenn ein + Entgeltanspruch über das Ende hinaus läuft.""" + self.ensure_one() + return self.version_id.contract_date_end or self.date_to + + def _at_end_arbeitstage_woche(self): + """Arbeitstage pro Woche des Vertragskalenders (Schema-Grundlage + der SV-Tage-Umrechnung: 5-AT-Woche, 6-WT-Woche, Teilzeitwoche).""" + self.ensure_one() + calendar = self.version_id.resource_calendar_id + days = {int(att.dayofweek) for att in calendar.attendance_ids} \ + if calendar else set() + return len(days) or 5 + + def _at_end_sv_tage(self, tage): + """SV-Tage aus abzurechnenden Urlaubstagen (Verwaltungspraxis + lb-url-15): je volle Woche à N Arbeitstage werden 7−N + Kalendertage hinzugerechnet (5-AT-Woche +2, 6-WT-Woche +1, + 2-Tage-Teilzeitwoche +5) — nur ganze Wochenblöcke.""" + self.ensure_one() + at_woche = self._at_end_arbeitstage_woche() + blocks = int(tage // at_woche) + return tage + blocks * (7 - at_woche) + + def _at_end_verlaengerungs_monate(self, sv_tage, start): + """[(Monatsanfang, Tage im Monat)] eines Verlängerungsfensters + ab dem übergebenen Starttag (§ 11 Abs 2 ASVG).""" + self.ensure_one() + ergebnis = {} + rest = int(sv_tage) + tag = start + while rest > 0: + monatsende = (tag.replace(day=28) + timedelta(days=4)) \ + .replace(day=1) - timedelta(days=1) + tage_dabei = min((monatsende - tag).days + 1, rest) + monat = tag.replace(day=1) + ergebnis[monat] = ergebnis.get(monat, 0) + tage_dabei + tag = monatsende + timedelta(days=1) + rest -= tage_dabei + return sorted(ergebnis.items()) + + def _at_end_rule_verlaengerung(self, rule, result_rules=None): + """[(Monatsanfang, Tage, Anteil)] der Regel-Basis nach § 11 + Abs 2: die Abgangsentschädigung belegt ZUERST die (fiktive) + Kündigungsfrist, die UEL SCHLIESSLICH die Urlaubstage — beide + Fenster laufen ab dem Tag nach dem arbeitsrechtlichen Ende + hintereinander (Gesetzeswortlaut: „zunächst die + Kündigungsentschädigung und im Anschluss die Ersatzleistung").""" + self.ensure_one() + start = self._at_end_arbeitsrechtliches_ende() + timedelta(days=1) + if rule.l10n_at_sv_verlaengerung == 'urlaubsersatz': + ffrist = self._at_input_amount('ATP_ABGANG_FFRIST_TAGE') + if ffrist: + start += timedelta(days=int(ffrist)) + tage = self._at_end_sv_tage(self._at_uel_tage()) + else: + ffrist = self._at_input_amount('ATP_ABGANG_FFRIST_TAGE') + tage = int(ffrist) if ffrist else 0 + monate = self._at_end_verlaengerungs_monate(tage, start) + if not monate: + return [] + gesamt = sum(tage for _monat, tage in monate) + return [(monat, tage, tage / float(gesamt)) + for monat, tage in monate] + + # ------------------------------------------------------------------ + # Eingabe-Helfer + # ------------------------------------------------------------------ + def _at_input_amount(self, code): + self.ensure_one() + linie = self.input_line_ids.filtered( + lambda i: i.input_type_id.code == code) + return linie[:1].amount or 0.0 + + # ------------------------------------------------------------------ + # Urlaubsersatzleistung — § 67 Abs 8 lit d, lb-url-15/16 + # ------------------------------------------------------------------ + def _at_uel_tage(self): + """Offene Urlaubstage (Arbeitstage; keine Rundung, lb-url-16).""" + self.ensure_one() + return self._at_input_amount('ATP_UEL_TAGE') + + def _at_uel_divisor(self): + """Tagewert-Divisor: /26 bei Werktags-, /22 bei 5-AT-Kalender + (lb-url-16).""" + self.ensure_one() + params = self._l10n_at_param('at_endabrechnung') + if self._at_end_arbeitstage_woche() >= 6: + return float(params['divisor_werktage']) + return float(params['divisor_arbeitstage']) + + def _at_uel_lfd(self): + """UEL-laufender Anteil: Tage × Monatsentgelt / Divisor — + läuft als laufender Bezug durch den Tarif des Austrittsmonats + und erhöht das Jahressechstel (nativ über bemessung='laufend').""" + self.ensure_one() + return self._at_uel_tage() * self._at_kv_gehalt_basis() \ + / self._at_uel_divisor() + + def _at_uel_sz_tageswert(self): + """SZ-Anteil je Urlaubstag: 2 Sonderzahlungen à Monatsentgelt + pro Jahr (KV § 11) → je Monat 2 × SZ-Basis/12, als Tageswert + (auch bei bereits überaliquot bezahlten SZ, lb-url-16).""" + self.ensure_one() + sz_monat = 2.0 * self._at_sz_basis(self.date_to) / 12.0 + return sz_monat / self._at_uel_divisor() + + def _at_uel_sz(self): + """UEL-Sonderzahlungsanteil: Tage × SZ-Tageswert.""" + self.ensure_one() + return self._at_uel_tage() * self._at_uel_sz_tageswert() + + # ------------------------------------------------------------------ + # Abgangsentschädigung — § 67 Abs 8 lit b (Fünftelregelung) + # ------------------------------------------------------------------ + def _at_fuenftel_regeln(self): + self.ensure_one() + return self.struct_id.rule_ids.filtered( + lambda rule: rule.l10n_at_s67_fuenftel) + + def _at_fuenftel_total(self, result_rules=None): + self.ensure_one() + total = 0.0 + for rule in self._at_fuenftel_regeln(): + if result_rules is not None: + if rule.code in result_rules: + total += result_rules[rule.code]['total'] + else: + linie = self.line_ids.filtered(lambda l: l.code == rule.code) + total += sum(linie.mapped('total')) + return total + + def _at_fuenftel_tarif_basis(self, result_rules=None): + """Tarifischer Teil (4/5) der Fünftel-Bezüge: Bemessung nach + Abzug der darauf entfallenden §-62-Beiträge (approximiert über + den effektiven DN-Satz der laufenden Basis; die HBG-Caps der + Verlängerungsmonate werden im SV-Pfad separat geführt); ein + Fünftel ist steuerfrei, gedeckelt mit einem Fünftel des + Neunfachen der monatlichen HBG (lb-end-21: 2026 6.930 × 9/5 + = 12.474).""" + self.ensure_one() + total = self._at_fuenftel_total(result_rules) + if total <= 0: + return 0.0 + satz = sum(self._l10n_at_sv_satz( + 'dn', komponente, + staffel_basis=self._l10n_at_sv_basis_roh(result_rules)) + for komponente in ('kv', 'pv', 'av', 'ak', 'wf')) + bemessung = max(total * (1.0 - satz / 100.0), 0.0) + deckel = float(self._l10n_at_param('at_sv_hbg_monatlich')) \ + * 9.0 / 5.0 + frei = min(bemessung / 5.0, deckel) + return bemessung - frei + + # ------------------------------------------------------------------ + # Engine-Anbindung: Tarif-Teil ohne Sechstel, LNK-Pflicht, SV-Umlegung + # ------------------------------------------------------------------ + def _l10n_at_bemessung_laufen(self, result_rules=None): + basis = super()._l10n_at_bemessung_laufen(result_rules) + # § 67 Abs 8 lit b: 4/5 tarifisch im Zuflussmonat; die Regel + # selbst ist neutral und erhöht brutto('laufend') nicht → kein + # Sechstel-Effekt (Abs 2 nicht anzuwenden). + return basis + self._at_fuenftel_tarif_basis(result_rules) + + def _at_lnk_basis(self, steuer, result_rules=None): + """Upstream-Überschreibung (Muster account_posting_private; + Vorlage payslip_private._at_lnk_basis — bei Änderungen dort + gegen diesen Pin neu abgleichen): Kündigungsentschädigungen + sind Arbeitslöhne iSd § 41 Abs 3 FLAG und unterliegen NICHT + den Abs-4-Ausschlüssen (lit b nennt nur § 67 Abs 3 und 6) — + sie sind VOR der Freibetragsstaffel in die LNK-Basis + aufzunehmen.""" + 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_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 += self._at_fuenftel_total(result_rules) + 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 _l10n_at_sv_basis_laufen_monate(self, result_rules=None): + """Upstream-Überschreibung (Muster account_posting_private; + Vorlage l10n_at_hr_payroll.sozialversicherung — bei Odoo- oder + Kern-Upgrades gegen diesen Pin neu abgleichen): identischer + Ablauf wie der Kern, zusätzlich werden Regeln mit AT-SV- + Verlängerung tageproportional auf die Verlängerungsmonate + gelegt. Der Standardanteil jedes Monats bleibt auf die volle + monatliche HBG gedeckelt, der Verlängerungsanteil auf den + Tageswert (monatliche HBG × Verlängerungstage/30, lb-url-15); + die Fenster laufen sequenziell (Abgang zuerst, UEL danach).""" + self.ensure_one() + gruppen = {} + verlaengerung = {} + tage_pro_monat = {} + for rule in self.struct_id.rule_ids: + if self._l10n_at_sv_art(rule) != 'laufend': + 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 + if rule.l10n_at_sv_verlaengerung != 'keine': + for monat, tage, anteil in \ + self._at_end_rule_verlaengerung(rule, result_rules): + verlaengerung[monat] = verlaengerung.get(monat, 0.0) \ + + werte['total'] * anteil + tage_pro_monat[monat] = \ + tage_pro_monat.get(monat, 0) + tage + continue + monat = (rule.l10n_at_beitragsmonat or self.date_from) \ + .replace(day=1) + gruppen[monat] = gruppen.get(monat, 0.0) + werte['total'] + standard = self.date_from.replace(day=1) + if standard in gruppen: + gruppen[standard] -= self._l10n_at_sv49_freie_laufen( + result_rules) + ergebnis = [] + for monat in sorted(set(gruppen) | set(verlaengerung)): + hbg = float(self._l10n_at_param('at_sv_hbg_monatlich', monat)) + basis = min(max(gruppen.get(monat, 0.0), 0.0), hbg) + if monat in verlaengerung: + tageswert = hbg * tage_pro_monat[monat] / 30.0 + basis += min(max(verlaengerung[monat], 0.0), tageswert) + ergebnis.append((monat, basis)) + return ergebnis \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv b/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv index 319a746..d04cb7a 100644 --- a/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv +++ b/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv @@ -23,3 +23,4 @@ access_l10n_at_payroll_pfaendung_user,L10n AT Payroll Pfaendung User,model_l10n_ access_l10n_at_payroll_pfaendung_manager,L10n AT Payroll Pfaendung Manager,model_l10n_at_payroll_pfaendung,hr_payroll.group_hr_payroll_manager,1,1,1,1 access_l10n_at_payroll_pendler_erklaerung_user,L10n AT Payroll Pendlererklaerung User,model_l10n_at_payroll_pendler_erklaerung,hr_payroll.group_hr_payroll_user,1,1,1,0 access_l10n_at_payroll_pendler_erklaerung_manager,L10n AT Payroll Pendlererklaerung Manager,model_l10n_at_payroll_pendler_erklaerung,hr_payroll.group_hr_payroll_manager,1,1,1,1 +access_l10n_at_payroll_endabrechnung_wizard,L10n AT Payroll Endabrechnung Wizard,model_l10n_at_payroll_endabrechnung_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1 diff --git a/addons/l10n_at_hr_payroll_private/tests/__init__.py b/addons/l10n_at_hr_payroll_private/tests/__init__.py index 70932b2..7eea1cf 100644 --- a/addons/l10n_at_hr_payroll_private/tests/__init__.py +++ b/addons/l10n_at_hr_payroll_private/tests/__init__.py @@ -17,3 +17,4 @@ from . import test_sachbezuege_private from . import test_sachbezuege2_private from . import test_reisekosten_private from . import test_pendler_private +from . import test_endabrechnung_private diff --git a/addons/l10n_at_hr_payroll_private/tests/test_endabrechnung_private.py b/addons/l10n_at_hr_payroll_private/tests/test_endabrechnung_private.py new file mode 100644 index 0000000..7a4f158 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_endabrechnung_private.py @@ -0,0 +1,298 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""AP14-A: Endabrechnungs-Zyklus — UEL-Zweikomponenten (§ 67 Abs 8 +lit d), SV-Verlängerung (§ 11 Abs 2 ASVG, sequentielle Fenster), +Abgangsentschädigung (§ 67 Abs 8 lit b Fünftel), Aufrechnung offener +Vorschüsse (§ 293 Abs 3 EO) und Zeitguthaben (§ 19e Abs 2 AZG).""" +from datetime import date, timedelta + +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class EndabrechnungPrivateTest(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.gruppe_ii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_ii') + self.calendar = self.env['resource.calendar'].create({ + 'name': 'AT Test End 40h Mo-Fr', + 'tz': 'Europe/Vienna', + 'hours_per_day': 8, + 'attendance_ids': [ + (0, 0, {'name': 'Tag %d' % weekday, + 'dayofweek': str(weekday), + 'hour_from': 8.0, 'hour_to': 16.0}) + for weekday in range(5) + ], + }) + self.env.company.resource_calendar_id = self.calendar + + def _calendar(self, weekdays): + return self.env['resource.calendar'].create({ + 'name': 'AT Test End %dTage' % len(weekdays), + 'tz': 'Europe/Vienna', + 'hours_per_day': 8, + 'attendance_ids': [ + (0, 0, {'name': 'Tag %d' % weekday, + 'dayofweek': str(weekday), + 'hour_from': 8.0, 'hour_to': 16.0}) + for weekday in weekdays + ], + }) + + def _employee(self, ueberzahlung=2640.0, + ende=date(2026, 3, 15), calendar=None): + employee = self.env['hr.employee'].create( + {'name': 'Test End privat'}) + 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)}) + version.write({ + 'contract_date_start': date(2020, 1, 1), + 'contract_date_end': ende, + 'ueberzahlung': ueberzahlung, + 'kv_id': self.kv2203.id, + 'kv_gruppe_id': self.gruppe_ii.id, + 'erfahrungsstufe': 1, + 'resource_calendar_id': (calendar or self.calendar).id, + }) + return employee + + def _slip(self, employee, monat=3, jahr=2026): + erster = date(jahr, monat, 1) + naechster = (erster + timedelta(days=32)).replace(day=1) + slip = self.env['hr.payslip'].create({ + 'name': 'Test-End %d/%d' % (monat, jahr), + 'employee_id': employee.id, + 'struct_id': self.structure.id, + 'date_from': erster, + 'date_to': naechster - timedelta(days=1), + }) + slip.compute_sheet() + return slip + + def _input(self, slip, code, amount): + typ = self.env['hr.payslip.input.type'].search( + [('code', '=', code)], limit=1) + self.env['hr.payslip.input'].create({ + 'payslip_id': slip.id, + 'input_type_id': typ.id, + 'amount': amount, + }) + slip.compute_sheet() + + def _total(self, slip, code): + return sum(slip.line_ids.filtered( + lambda line: line.code == code).mapped('total')) + + def _basis_monate(self, slip): + return dict(slip._l10n_at_sv_basis_laufen_monate()) + + # ------------------------------------------------------------------ + # RF-END-1: UEL-Zweikomponenten (§ 67 Abs 8 lit d) + # ------------------------------------------------------------------ + def test_01_uel_zwei_komponenten(self): + """11 offene AT à 120 € (2.640/22): UEL-lfd 1.320 € läuft + tarifisch und sechstelerhöhend; UEL-SZ 220 € (2×2.640/12/22) + als sonstiger Bezug; nur der laufende Anteil verlängert die + Pflichtversicherung.""" + emp_mit = self._employee() + emp_ohne = self._employee() + slip_mit = self._slip(emp_mit) + slip_ohne = self._slip(emp_ohne) + self._input(slip_mit, 'ATP_UEL_TAGE', 11) + + self.assertAlmostEqual( + self._total(slip_mit, 'URL_ERSATZ_LFD'), 1320.0, places=2) + self.assertAlmostEqual( + self._total(slip_mit, 'URL_ERSATZ_SZ'), 220.0, places=2) + svdn_diff = (slip_mit._l10n_at_svdn_gesamt() + - slip_ohne._l10n_at_svdn_gesamt()) + self.assertAlmostEqual( + slip_mit._l10n_at_bemessung_laufen() + - slip_ohne._l10n_at_bemessung_laufen() + svdn_diff, + 1320.0, places=2) + # Sechstel wächst um den annualisierten laufenden Anteil + # (60/Lohnsteuertage × 1.320; Teilmonat März: 15 Tage). + self.assertAlmostEqual( + slip_mit._l10n_at_sechstel() - slip_ohne._l10n_at_sechstel(), + 1320.0 * 60.0 / 15.0, places=2) + # SZ-Bemessung enthält den UEL-SZ-Anteil (SV-DN nach § 67 + # Abs 12 abziehen, um den Brutto-Anteil zu isolieren) + svdn_sz_diff = (slip_mit._l10n_at_svdn_sz() + - slip_ohne._l10n_at_svdn_sz()) + self.assertAlmostEqual( + slip_mit._l10n_at_sz_bemessung() + - slip_ohne._l10n_at_sz_bemessung() + svdn_sz_diff, + 220.0, places=2) + + # ------------------------------------------------------------------ + # RF-END-2: SV-Tage-Umrechnung und Verlängerungsmonate (§ 11 Abs 2) + # ------------------------------------------------------------------ + def test_02_sv_tage_und_verlaengerung(self): + """Schema je voller Woche à N AT + (7−N) KT: 23 AT (5-Woche) + → 31 SV-Tage; 12 WT (6-Woche) → 14; 6 AT (2-Tage-Woche) → 21 + (lb-url-15). Ende 15. 3.: Verlängerung 16. 3.–15. 4. — die + UEL-lfd-Basis liegt tageproportional auf den Monaten.""" + emp = self._employee() + slip = self._slip(emp) + self.assertEqual(slip._at_end_sv_tage(23), 31) + self.assertEqual(slip._at_end_sv_tage(10), 14) + + emp_6 = self._employee(calendar=self._calendar(range(6))) + self.assertEqual(self._slip(emp_6)._at_end_sv_tage(12), 14) + + emp_2 = self._employee(calendar=self._calendar([0, 3])) + self.assertEqual(self._slip(emp_2)._at_end_sv_tage(6), 21) + + self.assertEqual( + slip._at_end_verlaengerungs_monate( + 31, date(2026, 3, 16)), + [(date(2026, 3, 1), 16), (date(2026, 4, 1), 15)]) + + self._input(slip, 'ATP_UEL_TAGE', 23) + basis = self._basis_monate(slip) + self.assertAlmostEqual( + basis[date(2026, 4, 1)], 2760.0 * 15.0 / 31.0, places=2) + # März: KVGEO-Standardanteil + UEL-März-Anteil + self.assertAlmostEqual( + basis[date(2026, 3, 1)] - 2760.0 * 16.0 / 31.0, + 2640.0 * 10.0 / 22.0, places=2) + + # ------------------------------------------------------------------ + # RF-END-3: Abgangsentschädigung (§ 67 Abs 8 lit b, Fünftel) + # ------------------------------------------------------------------ + def test_03_abgang_fuenftel(self): + """9.000 € Abgangsentschädigung: Fünftel-LSt (1/5 steuerfrei + nach §-62-Abzug, Deckel 12.474 €), 4/5 tarifisch OHNE + Sechstelerhöhung; SV-Umlegung auf die 42 Tage Kündigungsfrist; + voll LNK-pflichtig.""" + emp_mit = self._employee(ueberzahlung=3000.0) + emp_ohne = self._employee(ueberzahlung=3000.0) + slip_mit = self._slip(emp_mit) + slip_ohne = self._slip(emp_ohne) + self._input(slip_mit, 'ATP_ABGANG', 9000.0) + self._input(slip_mit, 'ATP_ABGANG_FFRIST_TAGE', 42) + + self.assertAlmostEqual(self._total(slip_mit, 'ABGANG'), 9000.0, + places=2) + # Sechstel unverändert (Abs 2 nicht anzuwenden) + self.assertAlmostEqual( + slip_mit._l10n_at_sechstel() - slip_ohne._l10n_at_sechstel(), + 0.0, places=2) + # Tarifischer Teil: 9.000 minus §-62-Abzug, 1/5 frei + satz = sum(slip_mit._l10n_at_sv_satz( + 'dn', k, staffel_basis=slip_mit._l10n_at_sv_basis_roh()) + for k in ('kv', 'pv', 'av', 'ak', 'wf')) + bemessung = 9000.0 * (1.0 - satz / 100.0) + deckel = float(slip_mit._l10n_at_param('at_sv_hbg_monatlich')) \ + * 9.0 / 5.0 + erwartet = bemessung - min(bemessung / 5.0, deckel) + self.assertAlmostEqual( + slip_mit._at_fuenftel_tarif_basis(), erwartet, places=2) + svdn_diff = (slip_mit._l10n_at_svdn_gesamt() + - slip_ohne._l10n_at_svdn_gesamt()) + self.assertAlmostEqual( + slip_mit._l10n_at_bemessung_laufen() + - slip_ohne._l10n_at_bemessung_laufen() + svdn_diff, + erwartet, places=2) + # LNK-Basis um die volle Entschädigung erhöht + self.assertAlmostEqual( + slip_mit._at_lnk_basis('flaf') + - slip_ohne._at_lnk_basis('flaf'), 9000.0, places=2) + # SV-Umlegung: 16. 3.–26. 4. (Kündigungsfrist zuerst); im März + # liegt zusätzlich der aliquote KVGEO-Standardanteil + basis = self._basis_monate(slip_mit) + self.assertAlmostEqual( + basis[date(2026, 4, 1)], 9000.0 * 26.0 / 42.0, places=2) + self.assertAlmostEqual( + basis[date(2026, 3, 1)] - 3000.0 * 10.0 / 22.0, + 9000.0 * 16.0 / 42.0, places=2) + + # ------------------------------------------------------------------ + # RF-END-4: Aufrechnung offener Vorschüsse (§ 293 Abs 3 EO) + # ------------------------------------------------------------------ + def test_04_aufrechnung_vorschuss(self): + """2.000 € offener Vorschuss wird vom Netto abgezogen — ohne + jede Wirkung auf LSt-, SV- oder LNK-Basen (lb-end-06).""" + emp_mit = self._employee() + emp_ohne = self._employee() + slip_mit = self._slip(emp_mit) + slip_ohne = self._slip(emp_ohne) + self._input(slip_mit, 'ATP_ABRECHNUNG_VORSCHUSS', 2000.0) + + self.assertAlmostEqual(self._total(slip_mit, 'END_ABRECHNUNG_VORSCHUSS'), + 2000.0, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_bemessung_laufen() + - slip_ohne._l10n_at_bemessung_laufen(), 0.0, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_sv_basis_roh() + - slip_ohne._l10n_at_sv_basis_roh(), 0.0, places=2) + self.assertAlmostEqual( + slip_mit._at_lnk_basis('flaf') + - slip_ohne._at_lnk_basis('flaf'), 0.0, places=2) + self.assertAlmostEqual( + self._total(slip_mit, 'NET') - self._total(slip_ohne, 'NET'), + -2000.0, places=2) + + # ------------------------------------------------------------------ + # RF-END-5: Zeitguthaben (§ 19e Abs 2 AZG) und Zeitschulden + # ------------------------------------------------------------------ + def test_05_zeitguthaben_und_schulden(self): + """10 h Normalarbeitszeit-Guthaben + 50 % Zuschlag, 5 h + Zeitschulden negativ — beides laufender Bezug, KEIN §-68- + Freibetrag (lb-end-10).""" + emp = self._employee(ueberzahlung=3000.0) + slip = self._slip(emp) + self._input(slip, 'ATP_ZEIT_GUTHABEN', 10.0) + self._input(slip, 'ATP_ZEIT_SCHULDEN', 5.0) + + stunde = slip._at_grundstunde_norm() + self.assertAlmostEqual( + self._total(slip, 'ZEIT_GUTHABEN'), 10.0 * stunde * 1.5, + places=2) + self.assertAlmostEqual( + self._total(slip, 'ZEIT_SCHULDEN'), -5.0 * stunde, places=2) + regel = slip.struct_id.rule_ids.filtered( + lambda rule: rule.code == 'ZEIT_GUTHABEN') + self.assertEqual(regel.l10n_at_steuerbefreiung_68, 'keine') + + # ------------------------------------------------------------------ + # RF-END-6: Wizard legt den Endabrechnungs-Lauf vorbefüllt an + # ------------------------------------------------------------------ + def test_06_wizard(self): + """Der Wizard sammelt UEL, Abgang, Zeitguthaben und Vorschuss + und erzeugt den Payslip mit vorbefüllten Eingaben.""" + emp = self._employee() + # Version auf die private Structure-Type binden, damit der + # Wizard über version.struct_id die richtige Struktur findet. + emp.version_id.write({'structure_type_id': self.env.ref( + 'l10n_at_hr_payroll_private.structure_type_at_privat').id}) + wizard = self.env['l10n.at.payroll.endabrechnung.wizard'].create({ + 'employee_id': emp.id, + 'austrittsdatum': date(2026, 3, 15), + 'beendigungsart': 'einvernehmlich', + 'uel_tage': 11.0, + 'abgang_betrag': 9000.0, + 'abgang_ffrist_tage': 42.0, + 'zeit_guthaben_stunden': 10.0, + 'vorschuss_saldo': 500.0, + }) + ergebnis = wizard.action_payslip() + slip = self.env['hr.payslip'].browse(ergebnis['res_id']) + codes = set(slip.input_line_ids.mapped( + lambda line: line.input_type_id.code)) + self.assertIn('ATP_UEL_TAGE', codes) + self.assertIn('ATP_ABGANG', codes) + self.assertIn('ATP_ABRECHNUNG_VORSCHUSS', codes) + self.assertAlmostEqual( + self._total(slip, 'URL_ERSATZ_LFD'), 1320.0, places=2) + self.assertAlmostEqual(self._total(slip, 'ABGANG'), 9000.0, + places=2) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml b/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml new file mode 100644 index 0000000..5cfd5e8 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml @@ -0,0 +1,58 @@ + + + + + + + l10n.at.payroll.endabrechnung.wizard.form + l10n.at.payroll.endabrechnung.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Endabrechnung (Beendigung) + l10n.at.payroll.endabrechnung.wizard + form + new + + + +
\ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/wizard/__init__.py b/addons/l10n_at_hr_payroll_private/wizard/__init__.py index 6e926da..c7a46f8 100644 --- a/addons/l10n_at_hr_payroll_private/wizard/__init__.py +++ b/addons/l10n_at_hr_payroll_private/wizard/__init__.py @@ -1,3 +1,4 @@ # Part of the odoo-at-payroll project. License: LGPL-3. from . import kv_katalog_import -from . import bv_endabrechnung \ No newline at end of file +from . import bv_endabrechnung +from . import endabrechnung diff --git a/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py b/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py new file mode 100644 index 0000000..3af43da --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py @@ -0,0 +1,176 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""AP14-A: Endabrechnungs-Wizard — sammelt die offenen Posten der +Beendigungs-Abrechnung (Urlaubsersatzleistung, Abgangsentschädigung, +Zeitguthaben/-schulden, offene Vorschüsse) und legt den +Endabrechnungs-Lauf mit vorbefüllten Eingaben an. Reiner +Sammel-/Anlageprozess; die BV-Anwartschaft läuft über den eigenen +BV-Wizard (GP5), die Pfändung über die AP11-Maschine.""" +from datetime import timedelta + +from odoo import _, api, fields, models + + +class L10nAtEndabrechnungWizard(models.TransientModel): + _name = 'l10n.at.payroll.endabrechnung.wizard' + _description = 'Endabrechnung (Beendigung des Dienstverhältnisses)' + + employee_id = fields.Many2one( + 'hr.employee', string='Dienstnehmer:in', required=True) + austrittsdatum = fields.Date( + string='Austritt (Ende des Arbeitsverhältnisses)', required=True) + beendigungsart = fields.Selection( + [('ag_kuendigung', 'Kündigung durch den Arbeitgeber'), + ('an_kuendigung', 'Kündigung durch den Dienstnehmer'), + ('einvernehmlich', 'Einvernehmliche Auflösung'), + ('berechtigter_austritt', 'Berechtigter vorzeitiger Austritt'), + ('unberechtigter_austritt', + 'Unberechtigter vorzeitiger Austritt ' + '(keine UEL für die 5./6. Woche, § 10 Abs 2 UrlG; ' + 'kein Zeitguthaben-Zuschlag, § 19e Abs 2 AZG)'), + ('verschuldete_entlassung', 'Verschuldete Entlassung'), + ('unverschuldete_entlassung', 'Unverschuldete Entlassung'), + ('befristung', 'Zeitablauf (befristetes Dienstverhältnis)'), + ('tod', 'Tod (UEL an die Erben, § 10 Abs 5 UrlG)')], + string='Beendigungsart', required=True) + uel_tage = fields.Float( + string='Offene Urlaubstage (Arbeitstage)', + help='Resturlaub früherer Urlaubsjahre zur Gänze plus aliquoter ' + 'Anspruch des laufenden Urlaubsjahres abzüglich verbrauchter ' + 'Tage, ohne Rundung (§ 10 UrlG, lb-url-16). Für ' + 'unberechtigten vorzeitigen Austritt sind die Tage der 5. ' + 'und 6. Urlaubswoche des laufenden Jahres herauszurechnen.') + abgang_betrag = fields.Monetary( + string='Abgangsentschädigung (Brutto, § 67 Abs 8 lit b EStG)', + currency_field='currency_id', + help='Entschädigung für den Verzicht auf die Arbeitsleistung ' + 'während der (fiktiven) Kündigungsfrist — LSt: Fünftelregelung; ' + 'SV: Umlegung auf den Kündigungsfrist-Zeitraum (§ 11 Abs 2 ' + 'ASVG); voll lohnnebenkostenpflichtig.') + abgang_ffrist_tage = fields.Float( + string='Kündigungsfrist (Kalendertage)', + help='Kalendertage der (fiktiven) Kündigungsfrist, auf die die ' + 'pauschalierte Abgangsentschädigung sv-rechtlich umzulegen ' + 'ist (§ 11 Abs 2 ASVG).') + zeit_guthaben_stunden = fields.Float( + string='Zeitguthaben an Normalarbeitszeit (Stunden)', + help='Offenes Guthaben an Normalarbeitszeit aus durchrechenbarem ' + 'Arbeitszeitmodell — Abgeltung mit 50 % Zuschlag (§ 19e ' + 'Abs 2 AZG); bei unberechtigtem vorzeitigem Austritt kein ' + 'Zuschlag.') + zeit_schulden_stunden = fields.Float( + string='Zeitschulden (Stunden)', + help='Offene Zeitschulden (negative Zeitguthaben), abgerechnet ' + 'mit der aktuellen Normalstunde (lb-end-08).') + vorschuss_saldo = fields.Monetary( + string='Offener Vorschuss-/Darlehenssaldo', + currency_field='currency_id', + help='Bei Beendigung zur Gänze fälliger, noch offener ' + 'Vorschusssaldo (lb-end-06) — Rückverrechnung vom Netto ' + 'nach SV und Lohnsteuer, ohne Abgabenberührung ' + '(§ 293 Abs 3 EO).') + currency_id = fields.Many2one( + 'res.currency', default=lambda self: self.env.company.currency_id) + + uel_lfd_vorschau = fields.Monetary( + string='UEL laufender Anteil (Vorschau)', + currency_field='currency_id', compute='_compute_vorschau') + uel_sz_vorschau = fields.Monetary( + string='UEL Sonderzahlungsanteil (Vorschau)', + currency_field='currency_id', compute='_compute_vorschau') + sv_tage_vorschau = fields.Integer( + string='SV-Verlängerungstage (§ 11 Abs 2)', + compute='_compute_vorschau') + + @api.onchange('employee_id') + def _onchange_employee(self): + for wizard in self: + if wizard.employee_id: + wizard.austrittsdatum = ( + wizard.employee_id.version_id.contract_date_end + or fields.Date.today()) + + @api.depends('employee_id', 'austrittsdatum', 'uel_tage') + def _compute_vorschau(self): + for wizard in self: + wizard.uel_lfd_vorschau = 0.0 + wizard.uel_sz_vorschau = 0.0 + wizard.sv_tage_vorschau = 0 + if not wizard.employee_id or not wizard.austrittsdatum: + continue + slip = wizard._vorschau_payslip() + wizard.uel_lfd_vorschau = slip._at_uel_lfd() + wizard.uel_sz_vorschau = slip._at_uel_sz() + wizard.sv_tage_vorschau = slip._at_end_sv_tage( + wizard.uel_tage) + + def _vorschau_payslip(self): + """Flüchtiger Payslip-Kontext für die Vorschau-Berechnung (kein + Speichern): trägt Austrittsmonat, Struktur und die Eingaben.""" + self.ensure_one() + erster = self.austrittsdatum.replace(day=1) + letzter = (erster + timedelta(days=32)).replace(day=1) \ + - timedelta(days=1) + struct = self.employee_id.version_id.structure_id + if not struct: + struct = self.env.ref( + 'l10n_at_hr_payroll_private.structure_at_privat') + slip = self.env['hr.payslip'].new({ + 'employee_id': self.employee_id.id, + 'struct_id': struct.id, + 'date_from': erster, + 'date_to': letzter, + }) + eingaben = [('ATP_UEL_TAGE', self.uel_tage)] + slip.input_line_ids = [ + (0, 0, { + 'input_type_id': self.env['hr.payslip.input.type'] + .search([('code', '=', code)], limit=1).id, + 'amount': betrag, + }) + for code, betrag in eingaben if betrag + ] + return slip + + def _eingaben(self): + self.ensure_one() + return [ + ('ATP_UEL_TAGE', self.uel_tage), + ('ATP_ABGANG', self.abgang_betrag), + ('ATP_ABGANG_FFRIST_TAGE', self.abgang_ffrist_tage), + ('ATP_ZEIT_GUTHABEN', self.zeit_guthaben_stunden), + ('ATP_ZEIT_SCHULDEN', self.zeit_schulden_stunden), + ('ATP_ABRECHNUNG_VORSCHUSS', self.vorschuss_saldo), + ] + + def action_payslip(self): + """Endabrechnungs-Lauf im Austrittsmonat anlegen und die + gesammelten offenen Posten als Eingaben vorbefüllen.""" + self.ensure_one() + erster = self.austrittsdatum.replace(day=1) + letzter = (erster + timedelta(days=32)).replace(day=1) \ + - timedelta(days=1) + struct = self.employee_id.version_id.structure_id \ + or self.env.ref('l10n_at_hr_payroll_private.structure_at_privat') + slip = self.env['hr.payslip'].create({ + 'name': _('Endabrechnung %s') % self.employee_id.name, + 'employee_id': self.employee_id.id, + 'struct_id': struct.id, + 'date_from': erster, + 'date_to': letzter, + }) + typen = {code: self.env['hr.payslip.input.type'].search( + [('code', '=', code)], limit=1) + for code, _betrag in self._eingaben()} + self.env['hr.payslip.input'].create([{ + 'payslip_id': slip.id, + 'input_type_id': typen[code].id, + 'amount': betrag, + } for code, betrag in self._eingaben() if betrag]) + slip.compute_sheet() + return { + 'type': 'ir.actions.act_window', + 'name': _('Endabrechnung'), + 'res_model': 'hr.payslip', + 'view_mode': 'form', + 'res_id': slip.id, + } \ No newline at end of file