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