[IMP] l10n_at_hr_payroll_private: GP2 Entgelt-Engine (structures, WETs, rules, Rechenfall tests)
This commit is contained in:
@@ -42,13 +42,17 @@ werden im Kern-Modul `l10n_at_hr_payroll` gebaut und von beiden
|
||||
Produkten konsumiert; bei Bgld.-Auftragserteilung Anrechnung per
|
||||
Change-Request.
|
||||
|
||||
**Offen (GP2+):** Entgelt-Engine (KVGEO, AZG-Zuschläge, B13/B14,
|
||||
Urlaubsentgelt), Lohnsteuer/SV, Abfertigung Neu, Meldewesen/Belege,
|
||||
FI-Buchung UGB.
|
||||
**GP2 Entgelt-Engine (umgesetzt 2026-09-11):** Structure „AT
|
||||
Privatwirtschaft (KV)", AZG-Fenster-WETs, Regelinventar
|
||||
KVGEO/NACHT_SV/UE_*/MEHR_*/URL_UE/B13/B14/B14_RUECK/NETTO —
|
||||
vollständig `kv.wert`-getrieben (Rechenfälle `docs/rechenfaelle/`).
|
||||
|
||||
Tests: `test_kv_katalog` (Seed, Stichtags-Lookup, Versionierung,
|
||||
Eckwerte, Wizard/Sprungwarnung, Vertragsfelder) —
|
||||
`.venv/bin/python -m odoo -d <testdb> -i l10n_at_hr_payroll_private
|
||||
**Offen (GP3+):** Lohnsteuer/SV im Bundes-Kern (D3-Vorfinanzierung),
|
||||
Abfertigung Neu, Meldewesen/Belege, FI-Buchung UGB; All-in-UI-Bericht
|
||||
(GP8); B14 längster-Urlaubs-Teil-Verfeinerung.
|
||||
|
||||
Tests: `test_kv_katalog` + `test_entgelt_private` (GP2-Rechenfälle) —
|
||||
`.venv/bin/python -m odoo -d <testdb> -u l10n_at_hr_payroll,l10n_at_hr_payroll_private
|
||||
--test-tags /l10n_at_hr_payroll_private --test-enable --stop-after-init
|
||||
--addons-path=odoo_19.0+e.20260910/odoo-19.0+e.20260910/odoo/addons,addons`
|
||||
(Lokale GP1-Runtime grün am 2026-09-11, 8 Tests 0 Fehler.)
|
||||
(Lokal grün am 2026-09-11: 29/29 Tests.)
|
||||
@@ -1,7 +1,7 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
{
|
||||
'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.2.0.0',
|
||||
'author': 'ixsol / gem360',
|
||||
'website': 'https://gem360.at',
|
||||
'category': 'Human Resources/Payroll',
|
||||
@@ -23,6 +23,14 @@ Grundlagen: ``personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md`` und
|
||||
(``hr.version``: KV, Gruppe, Erfahrungsstufe, Überzahlung) sowie
|
||||
ein Import-Wizard für KV-Aufnahme und Jahresupdate (Vorschau,
|
||||
Sprungwarnung > 10 %, versionierte Werte — TASY-Muster).
|
||||
- **GP2 Entgelt-Engine (dieser Stand):** Structure „AT Privatwirtschaft
|
||||
(KV)" + Kategorien ATP_*, Work-Entry-Types für die AZG-Fenster-Matrix
|
||||
(ÜStd Tag/Nacht 20–6/22–6, So/Fei, Mehrarbeit § 4a-Band und § 19d
|
||||
Teilzeit), Regelinventar KVGEO/NACHT_SV/UE_*/MEHR_*/URL_UE/B13/B14/
|
||||
B14_RUECK/NETTO — vollständig datengetrieben über ``kv.wert``
|
||||
(Vorlauf 2026-09-11 verifiziert; Rechenfälle unter ``docs/rechenfaelle/``).
|
||||
All-in-Vertragsmodell (§ 2g AVRAG): Pauschale/Grundlohn-Ausweis am
|
||||
Vertrag + Deckungsrechnung.
|
||||
- **Seed D2 (Referenz-KVs R1):** SI-2203 „Handwerk und Gewerbe" +
|
||||
SI-2748 „Metallgewerbe" (Gehaltstabellen ab 1. 1. 2026, kuratiert aus
|
||||
den KV-Library-Volltexten).
|
||||
@@ -40,6 +48,10 @@ gem360-Brücke).
|
||||
'data/l10n.at.payroll.kv.gruppe.csv',
|
||||
'data/l10n.at.payroll.kv.gruppe.stufe.csv',
|
||||
'data/l10n.at.payroll.kv.wert.csv',
|
||||
'data/structure_private.xml',
|
||||
'data/work_entry_types_private.xml',
|
||||
'data/input_types_private.xml',
|
||||
'data/salary_rules_private.xml',
|
||||
'views/kv_views.xml',
|
||||
],
|
||||
'countries': ['at'],
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Manuelle Eingaben (GP2): Prämien/Ausgleichszahlungen ohne -->
|
||||
<!-- eigene Work-Entry-Quelle; Zuschläge mit Quelle laufen über -->
|
||||
<!-- die WET-Regeln. -->
|
||||
<record id="input_type_atp_pramie" model="hr.payslip.input.type">
|
||||
<field name="name">Prämie/Ausgleich (manuell)</field>
|
||||
<field name="code">ATP_PRAMIE</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -39,4 +39,6 @@ kv2748_wert_sz14_dezember,l10n_at_hr_payroll_private.kv_si_2748,sz14_no_urlaub_m
|
||||
kv2748_wert_ue_fenster_von,l10n_at_hr_payroll_private.kv_si_2748,ue_fenster_von,ÜStd-Zuschlag 100 % ab Uhr (§ 5 Abs 2),20,2026-01-01
|
||||
kv2748_wert_ue_fenster_bis,l10n_at_hr_payroll_private.kv_si_2748,ue_fenster_bis,ÜStd-Zuschlag 100 % bis Uhr (§ 5 Abs 2),6,2026-01-01
|
||||
kv2748_wert_nacht_von,l10n_at_hr_payroll_private.kv_si_2748,nacht_sv_fenster_von,Nacht-Sondervergütung ab Uhr (§ 6),22,2026-01-01
|
||||
kv2748_wert_nacht_bis,l10n_at_hr_payroll_private.kv_si_2748,nacht_sv_fenster_bis,Nacht-Sondervergütung bis Uhr (§ 6),6,2026-01-01
|
||||
kv2748_wert_nacht_bis,l10n_at_hr_payroll_private.kv_si_2748,nacht_sv_fenster_bis,Nacht-Sondervergütung bis Uhr (§ 6),6,2026-01-01
|
||||
kv2203_wert_sz_aliquote,l10n_at_hr_payroll_private.kv_si_2203,sz_aliquote_methode,Aliquotierung 13./14. bei Ein-/Austritt (§ 11 Abs 4),'kalendertage',2026-01-01
|
||||
kv2748_wert_sz_aliquote,l10n_at_hr_payroll_private.kv_si_2748,sz_aliquote_methode,Aliquotierung 13./14. bei Ein-/Austritt (§ 11 Abs 4),'kalendertage',2026-01-01
|
||||
|
@@ -0,0 +1,347 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- GP2 Regelinventar AT-Privatwirtschaft (Plan 4.2). -->
|
||||
<!-- Datenquelle ausschließlich kv.wert (Vorlauf 2026-09-11 -->
|
||||
<!-- verifiziert) bzw. Kern-Rule-Parameter — keine Literale in -->
|
||||
<!-- Regelcode. §-Anker je Code s. RECHTSQUELLEN-Privat 2/3 und -->
|
||||
<!-- docs/rechenfaelle/. -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- KVGEO: KV-Gruppenentgelt § 17 KV (Stufen-Lookup zum Stichtag, -->
|
||||
<!-- Überzahlung überschreibt, Teilzeit via work_time_rate, -->
|
||||
<!-- Prorate Ein-/Austritt § 17 Abs 2 als Arbeitstage-Faktor). -->
|
||||
<record id="rule_kvgeo" model="hr.salary.rule">
|
||||
<field name="name">KV-Gruppenentgelt (§ 17 KV)</field>
|
||||
<field name="code">KVGEO</field>
|
||||
<field name="sequence" eval="1"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = bool(employee.kv_id and employee.kv_gruppe_id and (employee.ueberzahlung or employee.erfahrungsstufe))</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# § 17 KV: Stufen-Mindestgrundgehalt zum Stichtag; individuelle
|
||||
# Überzahlung überschreibt den Katalogwert (Basis = Vollzeit-
|
||||
# Katalogwert × work_time_rate). Prorate Ein-/Austritt § 17 Abs 2:
|
||||
# Arbeitstage-Faktor aus dem Ressourcenkalender (user decision;
|
||||
# für Mo–Sa-Kalender identisch mit ÷26 × Werktage).
|
||||
result = payslip._at_kv_gehalt_basis() * payslip._at_arbeitstage_faktor()
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- NACHT_SV: Sondervergütung Nachtarbeit § 6 KV (22–6, €/h, -->
|
||||
<!-- kumulativ zu allen Vergütungen; Höchstzuschlag § 5 Abs 7 -->
|
||||
<!-- betrifft nur Zuschläge, nicht diese Vergütung). -->
|
||||
<record id="rule_nacht_sv" model="hr.salary.rule">
|
||||
<field name="name">Nacht-Sondervergütung (§ 6 KV, 22–6 Uhr)</field>
|
||||
<field name="code">NACHT_SV</field>
|
||||
<field name="sequence" eval="2"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = payslip._at_nacht_sv_stunden() > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# § 6 KV: €-Satz je Nachtstunde (22–6), unabhängig von Gehalt,
|
||||
# Werk-/Sonn-/Feiertag und Zuschlagsstatus; Version je date_from
|
||||
# (2748: 2,71 € ab 2026, 2,76 € ab 2027).
|
||||
satz = float(employee.kv_id._wert('nacht_sonderverguetung', payslip.date_to))
|
||||
result = payslip._at_nacht_sv_stunden() * satz
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- Überstunden § 5 KV: Grundvergütung + Zuschlag; Basis = -->
|
||||
<!-- Monatsgehalt ÷ Teilungsfaktor (2203: 150; 2748: 143). -->
|
||||
<record id="rule_ue_std" model="hr.salary.rule">
|
||||
<field name="name">Überstunden Tag (§ 5 Abs 2 KV)</field>
|
||||
<field name="code">UE_STD</field>
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = 'ATP_UE_STD' in worked_days and worked_days['ATP_UE_STD'].number_of_hours > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# Grundstunde ÷ Teilungsfaktor ÜStd (§ 5 Abs 6 KV) + Zuschlag
|
||||
# Tag (kv.wert ue_zuschlag_tag). Höchstzuschlag § 5 Abs 7 über
|
||||
# die WET-Klassifikation (je Stunde genau ein Zuschlagstyp).
|
||||
grund = payslip._at_grundstunde_ue()
|
||||
zuschlag = float(employee.kv_id._wert('ue_zuschlag_tag', payslip.date_to))
|
||||
stunden = worked_days['ATP_UE_STD'].number_of_hours
|
||||
result = stunden * grund * (1 + zuschlag / 100.0)
|
||||
]]></field>
|
||||
</record>
|
||||
<record id="rule_ue_nacht" model="hr.salary.rule">
|
||||
<field name="name">Überstunden Nacht 22–6 (§ 5 Abs 2 KV)</field>
|
||||
<field name="code">UE_NACHT</field>
|
||||
<field name="sequence" eval="11"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = 'ATP_UE_NACHT' in worked_days and worked_days['ATP_UE_NACHT'].number_of_hours > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# 100 %-Zuschlag-Fenster 20–6 (kv.wert ue_fenster_von/bis);
|
||||
# hier der Anteil 22–6 (€-Sondervergütung läuft über NACHT_SV).
|
||||
grund = payslip._at_grundstunde_ue()
|
||||
zuschlag = float(employee.kv_id._wert('ue_zuschlag_nacht', payslip.date_to))
|
||||
stunden = worked_days['ATP_UE_NACHT'].number_of_hours
|
||||
result = stunden * grund * (1 + zuschlag / 100.0)
|
||||
]]></field>
|
||||
</record>
|
||||
<record id="rule_ue_nacht_frueh" model="hr.salary.rule">
|
||||
<field name="name">Überstunden 20–22 Uhr (§ 5 Abs 2 KV)</field>
|
||||
<field name="code">UE_NACHT_FRUEH</field>
|
||||
<field name="sequence" eval="12"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = 'ATP_UE_NACHT_FRUEH' in worked_days and worked_days['ATP_UE_NACHT_FRUEH'].number_of_hours > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# Randfenster 20–22 des 100 %-Zuschlags — keine €-Sondervergütung
|
||||
# (Abgrenzung RF-Z6: Zuschlag ja, Nacht-SV nein).
|
||||
grund = payslip._at_grundstunde_ue()
|
||||
zuschlag = float(employee.kv_id._wert('ue_zuschlag_nacht', payslip.date_to))
|
||||
stunden = worked_days['ATP_UE_NACHT_FRUEH'].number_of_hours
|
||||
result = stunden * grund * (1 + zuschlag / 100.0)
|
||||
]]></field>
|
||||
</record>
|
||||
<record id="rule_ue_sofei" model="hr.salary.rule">
|
||||
<field name="name">Sonn-/Feiertagsüberstunden Tag (§ 5 Abs 4/5 KV)</field>
|
||||
<field name="code">UE_SOFEI</field>
|
||||
<field name="sequence" eval="13"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = 'ATP_UE_SOFEI' in worked_days and worked_days['ATP_UE_SOFEI'].number_of_hours > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# SonntagsüStd § 5 Abs 4, FeiertagsüStd ab erster Stunde § 5 Abs 5
|
||||
# iVm ARG — Zuschlag je kv.wert so_feiertag_zuschlag.
|
||||
grund = payslip._at_grundstunde_ue()
|
||||
zuschlag = float(employee.kv_id._wert('so_feiertag_zuschlag', payslip.date_to))
|
||||
stunden = worked_days['ATP_UE_SOFEI'].number_of_hours
|
||||
result = stunden * grund * (1 + zuschlag / 100.0)
|
||||
]]></field>
|
||||
</record>
|
||||
<record id="rule_ue_sofei_nacht" model="hr.salary.rule">
|
||||
<field name="name">Sonn-/Feiertagsüberstunden Nacht 22–6 (§ 5 Abs 4/5 KV)</field>
|
||||
<field name="code">UE_SOFEI_NACHT</field>
|
||||
<field name="sequence" eval="14"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = 'ATP_UE_SOFEI_NACHT' in worked_days and worked_days['ATP_UE_SOFEI_NACHT'].number_of_hours > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# Zuschlagskumulation So/Fei × Nacht: nur der höchste Zuschlag
|
||||
# (§ 5 Abs 7) = 100 % einmal (Höchstzuschlag über WET-Klasse);
|
||||
# €-Sondervergütung separat über NACHT_SV.
|
||||
grund = payslip._at_grundstunde_ue()
|
||||
zuschlag = float(employee.kv_id._wert('so_feiertag_zuschlag', payslip.date_to))
|
||||
stunden = worked_days['ATP_UE_SOFEI_NACHT'].number_of_hours
|
||||
result = stunden * grund * (1 + zuschlag / 100.0)
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- Mehrarbeit: Band KV-NAZ→40 h § 4a (nur KVs mit verkürzter -->
|
||||
<!-- NAZ, z. B. 2748: 38,5→40; Basis ÷ Teilungsfaktor Norm). -->
|
||||
<record id="rule_mehr_vz" model="hr.salary.rule">
|
||||
<field name="name">Mehrarbeit Band KV-NAZ→40 h (§ 4a KV)</field>
|
||||
<field name="code">MEHR_VZ</field>
|
||||
<field name="sequence" eval="20"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python"><![CDATA[
|
||||
result = 'ATP_MEHR_VZ' in worked_days and worked_days['ATP_MEHR_VZ'].number_of_hours > 0 and bool(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to, raise_if_not_found=False))
|
||||
]]></field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# § 4a KV: Zuschlag auf die Normalstunde (Basis ÷ Teilungsfaktor
|
||||
# Normalstunde, 2748: ÷ 167); zählt nicht gegen das ÜStd-Ausmaß;
|
||||
# Stunden mit > 50 %-Zuschlag sind ÜStd (§ 4a letzter Satz —
|
||||
# Klassifikation stromauf).
|
||||
grund = payslip._at_grundstunde_norm()
|
||||
zuschlag = float(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to))
|
||||
stunden = worked_days['ATP_MEHR_VZ'].number_of_hours
|
||||
result = stunden * grund * (1 + zuschlag / 100.0)
|
||||
]]></field>
|
||||
</record>
|
||||
<record id="rule_mehr_vz_nacht" model="hr.salary.rule">
|
||||
<field name="name">Mehrarbeit Band Nacht 22–6 (§ 4a KV)</field>
|
||||
<field name="code">MEHR_VZ_NACHT</field>
|
||||
<field name="sequence" eval="21"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python"><![CDATA[
|
||||
result = 'ATP_MEHR_VZ_NACHT' in worked_days and worked_days['ATP_MEHR_VZ_NACHT'].number_of_hours > 0 and bool(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to, raise_if_not_found=False))
|
||||
]]></field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
grund = payslip._at_grundstunde_norm()
|
||||
zuschlag = float(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to))
|
||||
stunden = worked_days['ATP_MEHR_VZ_NACHT'].number_of_hours
|
||||
result = stunden * grund * (1 + zuschlag / 100.0)
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- Mehrarbeit Teilzeit § 19d AZG: gesetzlich 25 % (Kern- -->
|
||||
<!-- Parameter); KV-Abweichung über kv.wert code -->
|
||||
<!-- mehrarbeit_teilzeit_zuschlag (user decision: KV vor Gesetz). -->
|
||||
<record id="rule_mehr_tz" model="hr.salary.rule">
|
||||
<field name="name">Mehrarbeit Teilzeit (§ 19d AZG)</field>
|
||||
<field name="code">MEHR_TZ</field>
|
||||
<field name="sequence" eval="22"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = 'ATP_MEHR_TZ' in worked_days and worked_days['ATP_MEHR_TZ'].number_of_hours > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# § 19d AZG: Stunden über Teilzeit-Ausmaß bis Vollzeit-NAZ;
|
||||
# Zuschlag 25 % gesetzlich (Kern-Parameter), KV kann besser
|
||||
# stellen (kv.wert-Code mehrarbeit_teilzeit_zuschlag).
|
||||
grund = payslip._at_grundstunde_norm()
|
||||
kv_zuschlag = employee.kv_id._wert('mehrarbeit_teilzeit_zuschlag', payslip.date_to, raise_if_not_found=False)
|
||||
zuschlag = float(kv_zuschlag) if kv_zuschlag else float(payslip._rule_parameter('at_mehrarbeit_teilzeit_zuschlag'))
|
||||
stunden = worked_days['ATP_MEHR_TZ'].number_of_hours
|
||||
result = stunden * grund * (1 + zuschlag / 100.0)
|
||||
]]></field>
|
||||
</record>
|
||||
<record id="rule_mehr_tz_nacht" model="hr.salary.rule">
|
||||
<field name="name">Mehrarbeit Teilzeit Nacht 22–6 (§ 19d AZG)</field>
|
||||
<field name="code">MEHR_TZ_NACHT</field>
|
||||
<field name="sequence" eval="23"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = 'ATP_MEHR_TZ_NACHT' in worked_days and worked_days['ATP_MEHR_TZ_NACHT'].number_of_hours > 0</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
grund = payslip._at_grundstunde_norm()
|
||||
kv_zuschlag = employee.kv_id._wert('mehrarbeit_teilzeit_zuschlag', payslip.date_to, raise_if_not_found=False)
|
||||
zuschlag = float(kv_zuschlag) if kv_zuschlag else float(payslip._rule_parameter('at_mehrarbeit_teilzeit_zuschlag'))
|
||||
stunden = worked_days['ATP_MEHR_TZ_NACHT'].number_of_hours
|
||||
result = stunden * grund * (1 + zuschlag / 100.0)
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- URL_UE: Ø regelmäßige Überstunden im Urlaubsmonat (§ 2 Abs 2 -->
|
||||
<!-- GKV iVm § 5 Abs 11 (2203) / Abs 10 (2748): regelmäßig = -->
|
||||
<!-- ≥ 7 der letzten 12 Kalendermonate; Durchschnitt 12 Monate). -->
|
||||
<record id="rule_url_ue" model="hr.salary.rule">
|
||||
<field name="name">Urlaubsentgelt: Ø regelmäßige Überstunden (§ 6 UrlG)</field>
|
||||
<field name="code">URL_UE</field>
|
||||
<field name="sequence" eval="30"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python"><![CDATA[
|
||||
result = payslip._at_urlaub_ue_avg()[0] and any(wd.work_entry_type_id.is_leave for wd in payslip.worked_days_line_ids)
|
||||
]]></field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# § 6 UrlG Abs 2: laufendes (monatsweises) Entgelt bleibt
|
||||
# unverändert (KVGEO läuft weiter); zusätzlich der monatliche
|
||||
# Durchschnitt der regelmäßigen ÜStd, anteilig nach
|
||||
# Urlaubstagen ÷ Arbeitstage des Monats (Konvention RF-U2).
|
||||
regelmaessig, avg = payslip._at_urlaub_ue_avg()
|
||||
urlaubstage = sum(wd.number_of_days for wd in payslip.worked_days_line_ids if wd.work_entry_type_id.is_leave)
|
||||
arbeitstage = payslip._at_arbeitstage(payslip.date_from, payslip.date_to)
|
||||
result = avg * urlaubstage / arbeitstage if arbeitstage and regelmaessig else 0.0
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- PRAM: manuelle Prämien/Ausgleichszahlungen (Input). -->
|
||||
<record id="rule_pram" model="hr.salary.rule">
|
||||
<field name="name">Prämie/Ausgleich (manuell)</field>
|
||||
<field name="code">PRAM</field>
|
||||
<field name="sequence" eval="40"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = 'ATP_PRAMIE' in inputs and inputs['ATP_PRAMIE'].amount</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
result = inputs['ATP_PRAMIE'].amount
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- B13/B14: 13./14. Bezug § 11 KV — begünstigte Bezüge iSd -->
|
||||
<!-- § 67 EStG (Markierung/Sechstel-Engine folgt GP3 im Kern). -->
|
||||
<record id="rule_b13" model="hr.salary.rule">
|
||||
<field name="name">13. Bezug (§ 11 KV)</field>
|
||||
<field name="code">B13</field>
|
||||
<field name="sequence" eval="50"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = payslip._at_b13_faellig()</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# Basis je kv.wert sz13_basis_monat: 'auszahlung' (2203) =
|
||||
# Monatsgehalt des Auszahlungsmonats; 'november' (2748) =
|
||||
# November-Gehalt (§ 11 Abs 2). Aliquote Ein-/Austritt § 11
|
||||
# Abs 4 über sz_aliquote_methode ('kalendertage': KT/365;
|
||||
# 'monate': volle Monate/12); Mischberechnung Beschäftigungs-
|
||||
# ausmaß (2203) über die Jahres-Versionen.
|
||||
basis_datum = payslip._at_sz_basis_datum('sz13_basis_monat')
|
||||
result = payslip._at_sz_basis(basis_datum) * payslip._at_sz_dienstzeit_faktor()
|
||||
]]></field>
|
||||
</record>
|
||||
<record id="rule_b14" model="hr.salary.rule">
|
||||
<field name="name">14. Bezug / Urlaubszuschuss (§ 11 KV)</field>
|
||||
<field name="code">B14</field>
|
||||
<field name="sequence" eval="51"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = payslip._at_b14_faellig()</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# Fälligkeit § 11 Abs 3: bei Urlaubsantritt (R1: erstfälliger
|
||||
# Urlaubsmonat, längster-Teil-Verfeinerung dokumentiert), ohne
|
||||
# Urlaub mit der Dezember-Abrechnung (sz14_no_urlaub_monat),
|
||||
# bei Austritt aliquote Endabrechnung; ohne KV-Code Fallback
|
||||
# Juni (user decision). Basis = Auszahlungsmonat-Gehalt.
|
||||
result = payslip._at_sz_basis(payslip.date_to) * payslip._at_sz_dienstzeit_faktor()
|
||||
]]></field>
|
||||
</record>
|
||||
<record id="rule_b14_rueck" model="hr.salary.rule">
|
||||
<field name="name">14. Bezug Rückverrechnung bei Austritt (§ 11 Abs 4 KV)</field>
|
||||
<field name="code">B14_RUECK</field>
|
||||
<field name="sequence" eval="52"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python"><![CDATA[
|
||||
austritt = version.contract_date_end and payslip.date_from <= version.contract_date_end <= payslip.date_to
|
||||
result = bool(austritt and payslip._at_sz_bereits_bezahlt('B14'))
|
||||
]]></field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
# § 11 Abs 4, 2. Satz: bei Austritt nach vollem B14-Bezug ist
|
||||
# der anteilige Überbezug in der Endabrechnung abzuziehen.
|
||||
bezahlt = payslip._at_sz_bereits_bezahlt('B14')
|
||||
result = bezahlt * (payslip._at_sz_dienstzeit_faktor() - 1.0)
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- NETTO (Kategoriensummen; DN-Abzüge folgen GP3/GP4). -->
|
||||
<record id="rule_netto" model="hr.salary.rule">
|
||||
<field name="name">Netto</field>
|
||||
<field name="code">NETTO</field>
|
||||
<field name="sequence" eval="100"/>
|
||||
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_netto"/>
|
||||
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
<field name="condition_select">none</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute"><![CDATA[
|
||||
result = categories['ATP_BRUTTO'] - categories['ATP_ABZUG']
|
||||
]]></field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- Structure-Type + Structure + Kategorien (Plan 4.2: EINE -->
|
||||
<!-- Structure je Typ; Angestellte/Arbeiter laufen über die -->
|
||||
<!-- Katalog-Mechanik lohn_basis + kv.wert, nicht über mehrere -->
|
||||
<!-- Structures). -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="structure_type_at_privat" model="hr.payroll.structure.type">
|
||||
<field name="name">AT Privatwirtschaft (KV)</field>
|
||||
<field name="default_schedule_pay">monthly</field>
|
||||
</record>
|
||||
|
||||
<record id="structure_at_privat" model="hr.payroll.structure">
|
||||
<field name="name">AT Privatwirtschaft Angestellte (KV)</field>
|
||||
<field name="code">AT_PRIVAT</field>
|
||||
<field name="type_id" ref="l10n_at_hr_payroll_private.structure_type_at_privat"/>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="use_worked_day_lines" eval="True"/>
|
||||
</record>
|
||||
<!-- default_struct nach der Structure setzen (gleicher Record-Update) -->
|
||||
<record id="structure_type_at_privat" model="hr.payroll.structure.type">
|
||||
<field name="default_struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
|
||||
</record>
|
||||
|
||||
<!-- Kategorien (country-Konsistenz zur Structure: base.at) -->
|
||||
<record id="category_atp_brutto" model="hr.salary.rule.category">
|
||||
<field name="name">AT Privat Brutto (Entgelt)</field>
|
||||
<field name="code">ATP_BRUTTO</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
</record>
|
||||
<record id="category_atp_abzug" model="hr.salary.rule.category">
|
||||
<field name="name">AT Privat Abzüge (DN; GP3/GP4)</field>
|
||||
<field name="code">ATP_ABZUG</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
</record>
|
||||
<record id="category_atp_netto" model="hr.salary.rule.category">
|
||||
<field name="name">AT Privat Netto</field>
|
||||
<field name="code">ATP_NETTO</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
</record>
|
||||
<record id="category_atp_dg" model="hr.salary.rule.category">
|
||||
<field name="name">AT Privat Dienstgeber (ohne Nettoeffekt; GP4+)</field>
|
||||
<field name="code">ATP_DG</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ============================================================ -->
|
||||
<!-- AT-Privatwirtschaft Work-Entry-Types (GP2). -->
|
||||
<!-- Fenster-Matrix (Vorlauf 2026-09-11, drei Nachtfenster): -->
|
||||
<!-- ÜStd-Zuschlag 100 % = 20–6 (§ 5 Abs 2 KV), Nacht-Sonder- -->
|
||||
<!-- vergütung = 22–6 (§ 6 KV, via l10n_at_nacht_sv-Flag, kumu- -->
|
||||
<!-- lativ zu allen Vergütungen), AZG-Nacht 22–5 (§ 12a) nur -->
|
||||
<!-- statusrelevant — keine Zahlung. Klassifikation der 100 %- -->
|
||||
<!-- Sonderbedingungen 2748 (3. ÜStd ab 19 Uhr, post-Nachtschicht,-->
|
||||
<!-- 24./31. 12.) erfolgt stromauf in der Zeiterfassung; die -->
|
||||
<!-- Regeln zahlen je Typ. -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- Normalarbeitsstunden in der Nachtzeit 22–6 (KV § 6: €-Sondervergütung) -->
|
||||
<record id="wett_att_nacht" model="hr.work.entry.type">
|
||||
<field name="name">Nachtarbeit (NAZ 22–6 Uhr)</field>
|
||||
<field name="color">1</field>
|
||||
<field name="display_code">ATN</field>
|
||||
<field name="code">ATP_ATT_NACHT</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="l10n_at_nacht_sv" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Überstunden -->
|
||||
<record id="wett_ue_std" model="hr.work.entry.type">
|
||||
<field name="name">Überstunde Tag (50 %)</field>
|
||||
<field name="color">4</field>
|
||||
<field name="display_code">UST</field>
|
||||
<field name="code">ATP_UE_STD</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="is_extra_hours" eval="True"/>
|
||||
</record>
|
||||
<record id="wett_ue_nacht" model="hr.work.entry.type">
|
||||
<field name="name">Überstunde Nacht 22–6 Uhr (100 % + Sondervergütung)</field>
|
||||
<field name="color">4</field>
|
||||
<field name="display_code">UNA</field>
|
||||
<field name="code">ATP_UE_NACHT</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="is_extra_hours" eval="True"/>
|
||||
<field name="l10n_at_nacht_sv" eval="True"/>
|
||||
</record>
|
||||
<record id="wett_ue_nacht_frueh" model="hr.work.entry.type">
|
||||
<field name="name">Überstunde 20–22 Uhr (100 %, ohne Sondervergütung)</field>
|
||||
<field name="color">4</field>
|
||||
<field name="display_code">UNF</field>
|
||||
<field name="code">ATP_UE_NACHT_FRUEH</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="is_extra_hours" eval="True"/>
|
||||
</record>
|
||||
<record id="wett_ue_sofei" model="hr.work.entry.type">
|
||||
<field name="name">Sonn-/Feiertagsüberstunde Tag (100 %)</field>
|
||||
<field name="color">4</field>
|
||||
<field name="display_code">USF</field>
|
||||
<field name="code">ATP_UE_SOFEI</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="is_extra_hours" eval="True"/>
|
||||
</record>
|
||||
<record id="wett_ue_sofei_nacht" model="hr.work.entry.type">
|
||||
<field name="name">Sonn-/Feiertagsüberstunde Nacht 22–6 (100 % + Sondervergütung)</field>
|
||||
<field name="color">4</field>
|
||||
<field name="display_code">USN</field>
|
||||
<field name="code">ATP_UE_SOFEI_NACHT</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="is_extra_hours" eval="True"/>
|
||||
<field name="l10n_at_nacht_sv" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Mehrarbeit: KV-Vollzeit-Band § 4a (2748: 38,5→40 h) -->
|
||||
<record id="wett_mehr_vz" model="hr.work.entry.type">
|
||||
<field name="name">Mehrarbeit Band KV-NAZ→40 h (§ 4a, 50 %)</field>
|
||||
<field name="color">5</field>
|
||||
<field name="display_code">MVZ</field>
|
||||
<field name="code">ATP_MEHR_VZ</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="is_extra_hours" eval="True"/>
|
||||
</record>
|
||||
<record id="wett_mehr_vz_nacht" model="hr.work.entry.type">
|
||||
<field name="name">Mehrarbeit Band Nacht 22–6 (§ 4a, 50 % + Sondervergütung)</field>
|
||||
<field name="color">5</field>
|
||||
<field name="display_code">MVN</field>
|
||||
<field name="code">ATP_MEHR_VZ_NACHT</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="is_extra_hours" eval="True"/>
|
||||
<field name="l10n_at_nacht_sv" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Mehrarbeit Teilzeit § 19d AZG (25 % gesetzlich; KV-Abweichung möglich) -->
|
||||
<record id="wett_mehr_tz" model="hr.work.entry.type">
|
||||
<field name="name">Mehrarbeit Teilzeit (§ 19d AZG)</field>
|
||||
<field name="color">5</field>
|
||||
<field name="display_code">MTZ</field>
|
||||
<field name="code">ATP_MEHR_TZ</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="is_extra_hours" eval="True"/>
|
||||
</record>
|
||||
<record id="wett_mehr_tz_nacht" model="hr.work.entry.type">
|
||||
<field name="name">Mehrarbeit Teilzeit Nacht 22–6 (§ 19d + Sondervergütung)</field>
|
||||
<field name="color">5</field>
|
||||
<field name="display_code">MTN</field>
|
||||
<field name="code">ATP_MEHR_TZ_NACHT</field>
|
||||
<field name="country_id" ref="base.at"/>
|
||||
<field name="is_extra_hours" eval="True"/>
|
||||
<field name="l10n_at_nacht_sv" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -13,14 +13,19 @@ KV-seitige Bemessung/Fälligkeit.
|
||||
- **2203:** Weihnachtsremuneration spätestens **30. 11.**
|
||||
(`sz13_monat` = 11); **2748:** 13. Monatsgehalt spätestens
|
||||
**1. 12.** (`sz13_monat` = 12). Engine: Auszahlung im
|
||||
Abrechnungslauf des Codierungsmonats.
|
||||
Abrechnungslauf des Codierungsmonats; bei Austritt aliquote
|
||||
Endabrechnung im Austrittsmonat (§ 11 Abs 4).
|
||||
|
||||
## RF-B2: Fälligkeit B14 (beide KVs, § 11 Abs 3)
|
||||
|
||||
- Bei **Urlaubsantritt** — bei mehreren Urlaubsteilen: der
|
||||
**längste** Teil, bei gleichen Teilen der **erste**.
|
||||
**längste** Teil, bei gleichen Teilen der **erste** (Engine R1:
|
||||
Auszahlung im erstfälligen Urlaubsmonat, sofern noch nicht
|
||||
bezahlt — längster-Teil-Verfeinerung dokumentiert).
|
||||
- Ohne angetretenen Urlaub im Kalenderjahr: mit der
|
||||
**Dezember-Abrechnung** (`sz14_no_urlaub_monat` = 12).
|
||||
- **Fallback ohne KV-Regelung** (user decision 2026-09-11):
|
||||
Auszahlung mit der **Juni-Abrechnung**.
|
||||
- Alternativ per BV/EA (2203 Abs 3): spätestens 30. 6. oder
|
||||
quartalsweise März/Juni/September/November — Option, nicht
|
||||
R1-Default.
|
||||
@@ -37,25 +42,31 @@ KV-seitige Bemessung/Fälligkeit.
|
||||
ohne Urlaub) würde den neuen Wert verwenden.
|
||||
|
||||
## RF-B4: Anwartschaft ×/12 und Aliquotierung bei Ein-/Austritt
|
||||
(§ 11 Abs 4, beide KVs)
|
||||
(§ 11 Abs 4, beide KVs — GP2-Umsetzung)
|
||||
|
||||
- Eintritt **1. 4. 2026**, VG II Stufe 2, SI-2203
|
||||
(2.179,68 €): B13 zum 30. 11. = 9/12 × 2.179,68 =
|
||||
**1.634,76 €** (Konvention volle Kalendermonate ×/12; die KVs
|
||||
sagen „aliquote Teil entsprechend der im Kalenderjahr
|
||||
zurückgelegten Dienstzeit" — Monats- vs. Tagesgenauigkeit ist
|
||||
eine GP2-Engine-Entscheidung, hier ×/12 dokumentiert).
|
||||
- **Aliquotierungsmethode je KV** (`kv.wert` `sz_aliquote_methode`,
|
||||
user decision 2026-09-11): **'kalendertage'** (Default/Fallback,
|
||||
beide Referenz-KVs): Faktor = Kalendertage Dienstzeit im
|
||||
Kalenderjahr ÷ 365 — Gehalt/365 × KT. 'monate' = volle
|
||||
Monate ÷ 12 (anteilige Monate taggenau) als Alternative.
|
||||
- Eintritt **1. 4. 2026**, VG II Stufe 2, SI-2203 (2.179,68 €):
|
||||
B13 zum 30. 11. = 275/365 × 2.179,68 = **1.642,23 €**.
|
||||
- Austritt **30. 9.** nach Bezugsberechtigung beider SZ: keine
|
||||
Rückverrechnung (9/12 korrekt bezogen).
|
||||
Rückverrechnung (275/365 korrekt bezogen).
|
||||
- Austritt **31. 3.** **nach** Bezug des B14 (Urlaub im Jänner):
|
||||
zu viel bezahlter Anteil (9/12) in der Endabrechnung
|
||||
zurückzurechnen (Abs 4, 2. Satz).
|
||||
zu viel bezahlter Anteil (90/365 korrekt, 275/365 bezahlt) in
|
||||
der Endabrechnung zurückzurechnen (Abs 4, 2. Satz) — Engine:
|
||||
`B14_RUECK` = bezahlt × (Faktor − 1), negativ.
|
||||
|
||||
## RF-B5: Mischberechnung Beschäftigungsausmaß (nur 2203, Abs 2)
|
||||
|
||||
- 1. 1.–30. 6. Vollzeit (2.179,68 €), 1. 7.–31. 12. 50 %
|
||||
(1.089,84 €): B13 = 6/12 × 2.179,68 + 6/12 × 1.089,84 =
|
||||
1.089,84 + 544,92 = **1.634,76 €**.
|
||||
(1.089,84 €) — **Kalendertag-gewichtete Mischung** über die
|
||||
Jahres-Versionen (GP2-Konvention, konsistent mit der
|
||||
Kalendertage-Aliquotierung):
|
||||
B13 = 2.179,68 × (181/365) + 1.089,84 × (184/365) =
|
||||
1.082,98 + 547,30 = **1.630,28 €**
|
||||
(Formel: Σ Gehalt_i × work_time_rate_i × KT-Anteil_i ÷ 365).
|
||||
- Überhöht bezahlter B14 ist mit der Weihnachtsremuneration
|
||||
rückzuverrechnen; zu niedrig bezahlter Differenzbetrag ist
|
||||
mit der Weihnachtsremuneration auszuzahlen.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
from . import kollektivvertrag
|
||||
from . import hr_version_private
|
||||
from . import hr_version_private
|
||||
from . import payslip_private
|
||||
@@ -0,0 +1,340 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class HrWorkEntryType(models.Model):
|
||||
_inherit = 'hr.work.entry.type'
|
||||
|
||||
l10n_at_nacht_sv = fields.Boolean(
|
||||
string='AT Nacht-Sondervergütung (22–6)',
|
||||
help='Stunden dieses Typs fallen ins KV-Nachtfenster 22–6 Uhr '
|
||||
'und gebühren der Nacht-Sondervergütung nach KV § 6 '
|
||||
'(zuschlagsunabhängig, kumulativ).')
|
||||
|
||||
|
||||
class HrVersionPrivate(models.Model):
|
||||
_inherit = 'hr.version'
|
||||
|
||||
# All-in / Überstundenpauschale § 2g AVRAG (Deckungsrechnung GP2):
|
||||
# zwingender Grundlohnausweis im Dienstzettel; Pauschale gilt bei
|
||||
# Unterdeckung nur als Akontozahlung (Deckungsprüfbericht).
|
||||
l10n_at_ue_pauschale_monatlich = fields.Monetary(
|
||||
string='ÜStd-Pauschale/Monat (All-in § 2g AVRAG)',
|
||||
currency_field='currency_id',
|
||||
help='Monatliche Überstundenpauschale lt. Arbeitsvertrag; '
|
||||
'0 = kein All-in.')
|
||||
l10n_at_grundlohn_ausweis = fields.Monetary(
|
||||
string='Grundlohn lt. Dienstzettel (§ 2g AVRAG)',
|
||||
currency_field='currency_id',
|
||||
help='Zwingend ausgewiesener Grundlohn (ohne ÜStd-Anteil) im '
|
||||
'Arbeitsvertrag/Dienstzettel.')
|
||||
|
||||
|
||||
class HrPayslip(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Arbeitstage (resource_calendar) — § 17 Abs 2 KV, user convention:
|
||||
# Werktage des KV werden als Arbeitstage aus dem Ressourcenkalender
|
||||
# gelesen (Mo–Fr-Kalender: Faktor = AT Dienstspanne / AT Zeitraum;
|
||||
# für Mo–Sa-Kalender identisch mit ÷26 × Werktage).
|
||||
# ------------------------------------------------------------------
|
||||
def _at_arbeitstage(self, von, bis):
|
||||
"""Arbeitstage (Kalender-Arbeitstage) im Intervall [von, bis]."""
|
||||
self.ensure_one()
|
||||
calendar = self.version_id.resource_calendar_id
|
||||
if not calendar or not von or not bis or von > bis:
|
||||
return 0
|
||||
days = {
|
||||
int(att.dayofweek)
|
||||
for att in calendar.attendance_ids
|
||||
}
|
||||
if not days:
|
||||
return 0
|
||||
count = 0
|
||||
tag = von
|
||||
while tag <= bis:
|
||||
if tag.weekday() in days:
|
||||
count += 1
|
||||
tag += timedelta(days=1)
|
||||
return count
|
||||
|
||||
def _at_arbeitstage_faktor(self):
|
||||
"""Prorate-Faktor Ein-/Austritt: Arbeitstage der Beschäftigungs-
|
||||
spanne (contract_date_*) im Abrechnungszeitraum ÷ Arbeitstage des
|
||||
vollen Zeitraums."""
|
||||
self.ensure_one()
|
||||
version = self.version_id
|
||||
von = max(self.date_from, version.contract_date_start) \
|
||||
if version.contract_date_start else self.date_from
|
||||
bis = min(self.date_to, version.contract_date_end) \
|
||||
if version.contract_date_end else self.date_to
|
||||
if von > bis:
|
||||
return 0.0
|
||||
total = self._at_arbeitstage(self.date_from, self.date_to)
|
||||
if not total:
|
||||
return 1.0
|
||||
return self._at_arbeitstage(von, bis) / float(total)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# KV-Basis / Grundstunden (Teilungsfaktoren § 5 Abs 6 KV)
|
||||
# ------------------------------------------------------------------
|
||||
def _at_kv_gehalt_basis(self, datum=None):
|
||||
"""Volles Monatsgehalt: Überzahlung überschreibt Katalog;
|
||||
Teilzeit über work_time_rate (Enterprise-Fracht: Bruchteil 0..1,
|
||||
compute aus Versionskalender ÷ Firmenzalender — D4-verifiziert)."""
|
||||
self.ensure_one()
|
||||
version = self.version_id
|
||||
datum = datum or self.date_to
|
||||
if version.ueberzahlung:
|
||||
gehalt = version.ueberzahlung
|
||||
else:
|
||||
pos = self.employee_id.kv_gruppe_id._get_gehalt(
|
||||
self.employee_id.erfahrungsstufe, datum)
|
||||
gehalt = pos.gehalt if pos else 0.0
|
||||
return gehalt * (version.work_time_rate or 1.0)
|
||||
|
||||
def _at_grundstunde_ue(self):
|
||||
"""ÜStd-Grundvergütung (und Zuschlagsbasis) = Monatsgehalt ÷
|
||||
Teilungsfaktor ÜStd (KV § 5 Abs 6: 2203 ÷ 150, 2748 ÷ 143)."""
|
||||
self.ensure_one()
|
||||
teiler = self.employee_id.kv_id._wert(
|
||||
'teilungsfaktor_ue', self.date_to, raise_if_not_found=False)
|
||||
if not teiler:
|
||||
return 0.0
|
||||
return self._at_kv_gehalt_basis() / float(teiler)
|
||||
|
||||
def _at_grundstunde_norm(self):
|
||||
"""Normalarbeitsstunde/Mehrarbeit = Monatsgehalt ÷
|
||||
Teilungsfaktor Normalstunde (2748: ÷ 167) bzw. Fallback
|
||||
÷ (4,33 × KV-Wochenstunden) ohne KV-Teilungsfaktor."""
|
||||
self.ensure_one()
|
||||
kv = self.employee_id.kv_id
|
||||
teiler = kv._wert(
|
||||
'teilungsfaktor_norm', self.date_to, raise_if_not_found=False)
|
||||
basis = self._at_kv_gehalt_basis()
|
||||
if teiler:
|
||||
return basis / float(teiler)
|
||||
naz = kv._wert('naz', self.date_to, raise_if_not_found=False) or 40
|
||||
return basis / (4.33 * float(naz))
|
||||
|
||||
def _at_nacht_sv_stunden(self):
|
||||
"""Stunden der Nacht-Sondervergütung: Work-Entry-Types mit
|
||||
Nacht-Flag (22–6-Fenster), unabhängig vom Zuschlagsstatus."""
|
||||
self.ensure_one()
|
||||
return sum(
|
||||
(wd.number_of_hours or 0.0)
|
||||
for wd in self.worked_days_line_ids
|
||||
if wd.work_entry_type_id.l10n_at_nacht_sv)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 13./14. Bezug (§ 11 KV) — Basis, Fälligkeit, Aliquotierung
|
||||
# ------------------------------------------------------------------
|
||||
def _at_sz_basis_datum(self, code):
|
||||
"""Stichtag der B13-/B14-Basis: 'november' → 30. 11. des
|
||||
Jahres (2748 B13), sonst Auszahlungsmonat = payslip-Stichtag."""
|
||||
self.ensure_one()
|
||||
methode = self.employee_id.kv_id._wert(
|
||||
code, self.date_to, raise_if_not_found=False) or 'auszahlung'
|
||||
if methode == 'november':
|
||||
return date(self.date_to.year, 11, 30)
|
||||
return self.date_to
|
||||
|
||||
def _at_jahres_versionen(self):
|
||||
"""Versionen des Mitarbeiters mit Überlappung im Kalenderjahr
|
||||
der Abrechnung (Mischberechnung Beschäftigungsausmaß, 2203
|
||||
§ 11 Abs 2)."""
|
||||
self.ensure_one()
|
||||
jahr = self.date_to.year
|
||||
von, bis = date(jahr, 1, 1), date(jahr, 12, 31)
|
||||
versionen = []
|
||||
for version in self.env['hr.version'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
]):
|
||||
v_von = version.date_start or von
|
||||
v_bis = version.date_end or bis
|
||||
if max(v_von, von) <= min(v_bis, bis):
|
||||
versionen.append((version, max(v_von, von), min(v_bis, bis)))
|
||||
return versionen
|
||||
|
||||
def _at_sz_basis(self, datum):
|
||||
"""B13-/B14-Basis zum Stichtag: einheitliches Beschäftigungs-
|
||||
ausmaß = aktuelles Monatsgehalt; Änderungen des Ausmaßes im
|
||||
Kalenderjahr → Kalendertag-gewichtete Mischung über die
|
||||
Jahres-Versionen (Konvention GP2, s. docs/rechenfaelle/b13b14.md)."""
|
||||
self.ensure_one()
|
||||
versionen = self._at_jahres_versionen()
|
||||
if len(versionen) <= 1:
|
||||
return self._at_kv_gehalt_basis(datum)
|
||||
if len({(v.work_time_rate or 100.0) for v, _, _ in versionen}) == 1:
|
||||
return self._at_kv_gehalt_basis(datum)
|
||||
# Mischberechnung: Kalendertag-Anteile je Version im Jahr
|
||||
jahr_von, jahr_bis = date(self.date_to.year, 1, 1), date(self.date_to.year, 12, 31)
|
||||
tage_jahr = (jahr_bis - jahr_von).days + 1
|
||||
summe_anteile, summe_gehaelter = 0.0, 0.0
|
||||
for version, v_von, v_bis in versionen:
|
||||
anteil = ((v_bis - v_von).days + 1) / float(tage_jahr)
|
||||
if version.ueberzahlung:
|
||||
gehalt = version.ueberzahlung
|
||||
else:
|
||||
pos = version.kv_gruppe_id._get_gehalt(
|
||||
version.erfahrungsstufe, v_bis)
|
||||
gehalt = pos.gehalt if pos else 0.0
|
||||
summe_anteile += anteil
|
||||
summe_gehaelter += gehalt * anteil \
|
||||
* (version.work_time_rate or 1.0)
|
||||
if not summe_anteile:
|
||||
return 0.0
|
||||
return summe_gehaelter / summe_anteile
|
||||
|
||||
def _at_sz_dienstzeit_faktor(self):
|
||||
"""Aliquote Ein-/Austritt (§ 11 Abs 4 KV): Methode je
|
||||
`sz_aliquote_methode` — 'kalendertage' (Default, user decision:
|
||||
Gehalt/365 × Kalendertage Dienstzeit) oder 'monate' (volle
|
||||
Monate ÷ 12, anteilige Monate taggenau)."""
|
||||
self.ensure_one()
|
||||
jahr = self.date_to.year
|
||||
jahr_von, jahr_bis = date(jahr, 1, 1), date(jahr, 12, 31)
|
||||
methode = self.employee_id.kv_id._wert(
|
||||
'sz_aliquote_methode', self.date_to,
|
||||
raise_if_not_found=False) or 'kalendertage'
|
||||
kt_gesamt, monate_gesamt = 0, 0.0
|
||||
for version, v_von, v_bis in self._at_jahres_versionen():
|
||||
kt_gesamt += (v_bis - v_von).days + 1
|
||||
tag = date(jahr, 1, 1)
|
||||
while tag <= date(jahr, 12, 31):
|
||||
if v_von <= tag <= v_bis:
|
||||
monate_gesamt += 1.0 / ((tag.replace(day=28) + timedelta(days=4)).day)
|
||||
tag += timedelta(days=1)
|
||||
if methode == 'monate':
|
||||
return min(monate_gesamt / 12.0, 1.0)
|
||||
return min(kt_gesamt / 365.0, 1.0)
|
||||
|
||||
def _at_sz_bereits_bezahlt(self, code):
|
||||
"""Summe bereits bezahlter B13-/B14-Linien im Kalenderjahr
|
||||
(andere, bestätigte Läufe)."""
|
||||
self.ensure_one()
|
||||
jahr = self.date_to.year
|
||||
linien = self.env['hr.payslip.line'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('code', '=', code),
|
||||
('date_from', '>=', date(jahr, 1, 1)),
|
||||
('date_from', '<=', date(jahr, 12, 31)),
|
||||
])
|
||||
linien = linien.filtered(
|
||||
lambda l: l.slip_id.state not in ('draft', 'cancel')
|
||||
and l.slip_id.id != self.id)
|
||||
return sum(linien.mapped('total'))
|
||||
|
||||
def _at_urlaubstage_jahr(self):
|
||||
"""Urlaubs-Work-Entries (is_leave) des Kalenderjahres."""
|
||||
self.ensure_one()
|
||||
jahr = self.date_to.year
|
||||
eintraege = self.env['hr.work.entry'].search([
|
||||
('version_id', 'in', self.employee_id.version_ids.ids),
|
||||
('work_entry_type_id.is_leave', '=', True),
|
||||
])
|
||||
return sum(
|
||||
1 for we in eintraege
|
||||
if we.date.year == jahr)
|
||||
|
||||
def _at_b14_faellig(self):
|
||||
"""B14-Fälligkeit § 11 Abs 3 KV: 'urlaub' → bei Urlaubsantritt
|
||||
(längster Teil — R1: Auszahlung im erstfälligen Urlaubsmonat,
|
||||
sofern nicht bereits bezahlt); ohne angetretenen Urlaub im
|
||||
Dezember (`sz14_no_urlaub_monat`); bei Austritt aliquote
|
||||
Endabrechnung. Fallback ohne KV-Code: Juni-Abrechnung
|
||||
(user decision)."""
|
||||
self.ensure_one()
|
||||
kv = self.employee_id.kv_id
|
||||
faelligkeit = kv._wert(
|
||||
'sz14_faelligkeit', self.date_to, raise_if_not_found=False)
|
||||
if faelligkeit != 'urlaub':
|
||||
return self.date_to.month == 6 # Fallback Juni-Abrechnung
|
||||
if self._at_sz_bereits_bezahlt('B14'):
|
||||
return False
|
||||
austritt = (
|
||||
self.version_id.contract_date_end
|
||||
and self.date_from <= self.version_id.contract_date_end <= self.date_to)
|
||||
urlaub_im_zeitraum = any(
|
||||
wd.work_entry_type_id.is_leave
|
||||
for wd in self.worked_days_line_ids)
|
||||
if urlaub_im_zeitraum or austritt:
|
||||
return True
|
||||
no_urlaub_monat = int(kv._wert(
|
||||
'sz14_no_urlaub_monat', self.date_to,
|
||||
raise_if_not_found=False) or 12)
|
||||
return (self.date_to.month == no_urlaub_monat
|
||||
and not self._at_urlaubstage_jahr())
|
||||
|
||||
def _at_b13_faellig(self):
|
||||
"""B13-Fälligkeit: Auszahlmonat je KV (`sz13_monat`) bzw.
|
||||
bei Austritt aliquote Endabrechnung (§ 11 Abs 4 KV)."""
|
||||
self.ensure_one()
|
||||
if self._at_sz_bereits_bezahlt('B13'):
|
||||
return False
|
||||
monat = int(self.employee_id.kv_id._wert(
|
||||
'sz13_monat', self.date_to, raise_if_not_found=False) or 12)
|
||||
austritt = (
|
||||
self.version_id.contract_date_end
|
||||
and self.date_from <= self.version_id.contract_date_end <= self.date_to)
|
||||
return self.date_to.month == monat or austritt
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Urlaubsentgelt — regelmäßige Überstunden (§ 2 Abs 2 GKV;
|
||||
# 7-von-12-Regel: KV § 5 Abs 11 (2203) / Abs 10 (2748))
|
||||
# ------------------------------------------------------------------
|
||||
def _at_urlaub_ue_avg(self):
|
||||
"""(regelmaessig, durchschnittlicher monatlicher ÜStd-Betrag
|
||||
der letzten 12 Monate) — nur ÜStd-Regeln, ohne Mehrarbeit.
|
||||
Achtung: Lohnzettel-Lines tragen Regel-Codes (UE_*), nicht
|
||||
WET-Codes (ATP_*)."""
|
||||
self.ensure_one()
|
||||
codes = ('UE_STD', 'UE_NACHT', 'UE_NACHT_FRUEH',
|
||||
'UE_SOFEI', 'UE_SOFEI_NACHT')
|
||||
von = fields.Date.subtract(self.date_from, months=12)
|
||||
linien = self.env['hr.payslip.line'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('code', 'in', list(codes)),
|
||||
('date_from', '>=', von),
|
||||
]).filtered(lambda l: l.slip_id.state not in ('draft', 'cancel'))
|
||||
if not linien:
|
||||
return False, 0.0
|
||||
monate = {l.slip_id.id for l in linien}
|
||||
return len(monate) >= 7, sum(l.total for l in linien) / 12.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# All-in-Deckungsrechnung (§ 2g AVRAG) — minimale GP2-Umsetzung:
|
||||
# Jahresvergleich geschuldete ÜStd-Vergütung vs. erhaltene Pauschale
|
||||
# ------------------------------------------------------------------
|
||||
def _at_allin_deckung(self):
|
||||
"""Deckungsrechnung für Verträge mit ÜStd-Pauschale:
|
||||
{'pauschale': x, 'geschuldet': y, 'unterdeckung': z}."""
|
||||
self.ensure_one()
|
||||
version = self.version_id
|
||||
pauschale = version.l10n_at_ue_pauschale_monatlich or 0.0
|
||||
if not pauschale:
|
||||
return {'pauschale': 0.0, 'geschuldet': 0.0, 'unterdeckung': 0.0}
|
||||
jahr = self.date_to.year
|
||||
codes = ('UE_STD', 'UE_NACHT', 'UE_NACHT_FRUEH',
|
||||
'UE_SOFEI', 'UE_SOFEI_NACHT')
|
||||
linien = self.env['hr.payslip.line'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('code', 'in', list(codes)),
|
||||
('date_from', '>=', date(jahr, 1, 1)),
|
||||
('date_from', '<=', date(jahr, 12, 31)),
|
||||
]).filtered(lambda l: l.slip_id.state not in ('draft', 'cancel'))
|
||||
geschuldet = sum(linien.mapped('total'))
|
||||
monate = 12 \
|
||||
if not version.contract_date_end \
|
||||
or version.contract_date_end.year > jahr \
|
||||
else version.contract_date_end.month
|
||||
erhaltene = pauschale * monate
|
||||
unterdeckung = max(geschuldet - erhaltene, 0.0)
|
||||
return {
|
||||
'pauschale': erhaltene,
|
||||
'geschuldet': geschuldet,
|
||||
'unterdeckung': unterdeckung,
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
from . import test_kv_katalog
|
||||
from . import test_kv_katalog
|
||||
from . import test_entgelt_private
|
||||
@@ -0,0 +1,402 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""GP2 Entgelt-Engine — Rechenfälle (docs/rechenfaelle/) als Fixtures:
|
||||
KVGEO/Prorate, Teilzeit, Überzahlung, ÜStd/Zuschläge/Nacht-€,
|
||||
Mehrarbeit § 4a/§ 19d, B13/B14-Fälligkeit/-Basis/-Aliquotierung,
|
||||
Rückverrechnung, Mischberechnung, Urlaubsentgelt-ÜStd-Ø.
|
||||
|
||||
Hinweise: Arbeitstage aus dem Ressourcenkalender (user decision);
|
||||
B13/B14-Aliquotierung 'kalendertage' (KT/365); UE-Testeinträge
|
||||
liegen außerhalb der Soll-Arbeitszeit (8–16), damit validierte
|
||||
Läufe keine Überlappung mit Kalender-Einträgen haben."""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class EntgeltPrivateTest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.structure = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.structure_at_privat')
|
||||
self.kv2203 = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.kv_si_2203')
|
||||
self.kv2748 = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.kv_si_2748')
|
||||
self.gruppe_2203_ii = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.kv2203_grp_ii')
|
||||
self.gruppe_2748_ii = self.env.ref(
|
||||
'l10n_at_hr_payroll_private.kv2748_grp_ii')
|
||||
self.wet_attendance = self.env.ref(
|
||||
'hr_work_entry.work_entry_type_attendance')
|
||||
self.wet_leave = self.env.ref(
|
||||
'hr_work_entry.work_entry_type_leave')
|
||||
self.calendar = self.env['resource.calendar'].create({
|
||||
'name': 'AT Test 40h Mo-Fr',
|
||||
'tz': 'Europe/Vienna',
|
||||
'hours_per_day': 8,
|
||||
'attendance_ids': [
|
||||
(0, 0, {
|
||||
'name': 'Tag %d' % wd,
|
||||
'dayofweek': str(wd),
|
||||
'hour_from': 8.0,
|
||||
'hour_to': 16.0,
|
||||
})
|
||||
for wd in range(5)
|
||||
],
|
||||
})
|
||||
# Referenzkalender für work_time_rate (Fraction: Version ÷ Firma)
|
||||
self.env.company.resource_calendar_id = self.calendar
|
||||
self.calendar_teilzeit = self.env['resource.calendar'].create({
|
||||
'name': 'AT Test 20h Mo-Fr',
|
||||
'tz': 'Europe/Vienna',
|
||||
'hours_per_day': 4,
|
||||
'attendance_ids': [
|
||||
(0, 0, {
|
||||
'name': 'Tag %d' % wd,
|
||||
'dayofweek': str(wd),
|
||||
'hour_from': 8.0,
|
||||
'hour_to': 12.0,
|
||||
})
|
||||
for wd in range(5)
|
||||
],
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _employee(self, kv=None, gruppe=None, stufe=None, calendar=None,
|
||||
version_vals=None, new_version_vals=None):
|
||||
employee = self.env['hr.employee'].create({
|
||||
'name': 'Test General-AT Entgelt',
|
||||
})
|
||||
version = self.env['hr.version'].search(
|
||||
[('employee_id', '=', employee.id)], limit=1)
|
||||
if not version:
|
||||
version = self.env['hr.version'].create({
|
||||
'employee_id': employee.id,
|
||||
'date_start': date(2020, 1, 1),
|
||||
})
|
||||
vals = {
|
||||
'date_start': date(2020, 1, 1),
|
||||
'contract_date_start': date(2020, 1, 1),
|
||||
'kv_id': (kv or self.kv2203).id,
|
||||
'kv_gruppe_id': (gruppe or self.gruppe_2203_ii).id,
|
||||
'erfahrungsstufe': stufe or 1,
|
||||
'resource_calendar_id': (calendar or self.calendar).id,
|
||||
}
|
||||
if version_vals:
|
||||
vals.update(version_vals)
|
||||
if 'date_start' in version_vals \
|
||||
and 'contract_date_start' not in version_vals:
|
||||
vals['contract_date_start'] = version_vals['date_start']
|
||||
version.write(vals)
|
||||
if new_version_vals:
|
||||
new_vals = dict(new_version_vals)
|
||||
start = new_vals.pop('date_start')
|
||||
if not version.date_end:
|
||||
version.write({'date_end': start - timedelta(days=1),
|
||||
'contract_date_end': start - timedelta(days=1)})
|
||||
vals2 = {
|
||||
'employee_id': employee.id,
|
||||
'kv_id': (kv or self.kv2203).id,
|
||||
'kv_gruppe_id': (gruppe or self.gruppe_2203_ii).id,
|
||||
'erfahrungsstufe': stufe or 1,
|
||||
'resource_calendar_id': (calendar or self.calendar).id,
|
||||
}
|
||||
vals2.update(new_vals)
|
||||
v2 = self.env['hr.version'].create(vals2)
|
||||
# date_start ist computed: date_version + contract_date_start
|
||||
# setzen (create erzwingt date_version = heute)
|
||||
v2.write({'date_version': start,
|
||||
'contract_date_start': start})
|
||||
return employee
|
||||
|
||||
def _month_end(self, year, month):
|
||||
if month == 12:
|
||||
return date(year, 12, 31)
|
||||
return date(year, month + 1, 1) - timedelta(days=1)
|
||||
|
||||
def _payslip(self, employee, month, year=2026):
|
||||
return self.env['hr.payslip'].create({
|
||||
'name': 'Test-Lohnzettel %d/%d' % (month, year),
|
||||
'employee_id': employee.id,
|
||||
'struct_id': self.structure.id,
|
||||
'date_from': date(year, month, 1),
|
||||
'date_to': self._month_end(year, month),
|
||||
})
|
||||
|
||||
def _compute(self, employee, month, year=2026, work_entries=(),
|
||||
confirm=False):
|
||||
"""work_entries: (WET, Tag, Stunden) — Odoo 19 Work-Entries
|
||||
sind tageweise (date + duration)."""
|
||||
slip = self._payslip(employee, month, year=year)
|
||||
for wet, tag, stunden in work_entries:
|
||||
self.env['hr.work.entry'].create({
|
||||
'name': 'Test-Eintrag',
|
||||
'version_id': slip.version_id.id,
|
||||
'employee_id': slip.employee_id.id,
|
||||
'work_entry_type_id': wet.id,
|
||||
'date': tag,
|
||||
'duration': stunden,
|
||||
})
|
||||
slip.compute_sheet()
|
||||
if confirm:
|
||||
slip.action_payslip_done()
|
||||
return slip
|
||||
|
||||
def _total(self, slip, code):
|
||||
lines = slip.line_ids.filtered(lambda l: l.code == code)
|
||||
return sum(lines.mapped('total'))
|
||||
|
||||
def _wet(self, xmlid):
|
||||
return self.env.ref(xmlid)
|
||||
|
||||
def _remove_attendance(self, slip, day):
|
||||
"""Urlaub ersetzt Kalender-Anwesenheit (Produktionsmuster der
|
||||
Leave-Verarbeitung) — nötig für validierte Läufe."""
|
||||
self.env['hr.work.entry'].search([
|
||||
('version_id', '=', slip.version_id.id),
|
||||
('work_entry_type_id', '=', self.wet_attendance.id),
|
||||
('date', '=', day),
|
||||
]).unlink()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# KVGEO (RF-K1…K4)
|
||||
# ------------------------------------------------------------------
|
||||
def test_01_kvgeo_vollmonat(self):
|
||||
employee = self._employee(stufe=1)
|
||||
slip = self._compute(employee, 11)
|
||||
self.assertAlmostEqual(self._total(slip, 'KVGEO'), 2059.41, places=2)
|
||||
|
||||
def test_02_kvgeo_eintritt_arbeitstage(self):
|
||||
"""RF-K2: Eintritt 16. 11. 2026 — 11 von 21 Arbeitstagen."""
|
||||
employee = self._employee(
|
||||
stufe=1, version_vals={'date_start': date(2026, 11, 16)})
|
||||
slip = self._compute(employee, 11)
|
||||
self.assertAlmostEqual(
|
||||
self._total(slip, 'KVGEO'), 2059.41 * 11 / 21, places=2)
|
||||
|
||||
def test_03_kvgeo_teilzeit(self):
|
||||
employee = self._employee(
|
||||
stufe=1, calendar=self.calendar_teilzeit)
|
||||
slip = self._compute(employee, 11)
|
||||
self.assertAlmostEqual(self._total(slip, 'KVGEO'), 1029.71, places=2)
|
||||
|
||||
def test_04_kvgeo_ueberzahlung(self):
|
||||
employee = self._employee(
|
||||
stufe=1, version_vals={'ueberzahlung': 2400.0})
|
||||
slip = self._compute(employee, 11)
|
||||
self.assertAlmostEqual(self._total(slip, 'KVGEO'), 2400.0, places=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Zuschläge / Nacht (RF-Z1…Z8) — Teststunden außerhalb der
|
||||
# Soll-Zeit (ÜStd), Fenster je WET.
|
||||
# ------------------------------------------------------------------
|
||||
def test_05_ue_std_tag(self):
|
||||
"""RF-Z1: 2 ÜStd Tag = 2 × (2059,41/150) × 1,5 = 41,19."""
|
||||
employee = self._employee(stufe=1)
|
||||
slip = self._compute(employee, 11, work_entries=[
|
||||
(self._wet('l10n_at_hr_payroll_private.wett_ue_std'),
|
||||
date(2026, 11, 5), 2.0),
|
||||
])
|
||||
self.assertAlmostEqual(self._total(slip, 'UE_STD'), 41.19, places=2)
|
||||
|
||||
def test_06_ue_nacht_und_sonderverguetung(self):
|
||||
"""RF-Z2: ÜStd 22–24 Uhr: 100 % Zuschlag + 2,62 €/h."""
|
||||
employee = self._employee(stufe=1)
|
||||
slip = self._compute(employee, 11, work_entries=[
|
||||
(self._wet('l10n_at_hr_payroll_private.wett_ue_nacht'),
|
||||
date(2026, 11, 5), 2.0),
|
||||
])
|
||||
self.assertAlmostEqual(self._total(slip, 'UE_NACHT'), 54.92, places=2)
|
||||
self.assertAlmostEqual(self._total(slip, 'NACHT_SV'), 5.24, places=2)
|
||||
|
||||
def test_07_ue_sofei_nacht_hoechstzuschlag(self):
|
||||
"""RF-Z4: So/Fei-Nachtüstd: Zuschlag nur einmal (100 %) + €."""
|
||||
employee = self._employee(stufe=1)
|
||||
slip = self._compute(employee, 11, work_entries=[
|
||||
(self._wet('l10n_at_hr_payroll_private.wett_ue_sofei_nacht'),
|
||||
date(2026, 11, 8), 1.0),
|
||||
])
|
||||
self.assertAlmostEqual(
|
||||
self._total(slip, 'UE_SOFEI_NACHT'), 27.46, places=2)
|
||||
self.assertAlmostEqual(self._total(slip, 'NACHT_SV'), 2.62, places=2)
|
||||
|
||||
def test_08_ue_nacht_frueh_ohne_sv(self):
|
||||
"""RF-Z6: 20–22 Uhr Zuschlag 100 %, aber KEINE Sondervergütung."""
|
||||
employee = self._employee(stufe=1)
|
||||
slip = self._compute(employee, 11, work_entries=[
|
||||
(self._wet('l10n_at_hr_payroll_private.wett_ue_nacht_frueh'),
|
||||
date(2026, 11, 5), 1.0),
|
||||
])
|
||||
self.assertAlmostEqual(
|
||||
self._total(slip, 'UE_NACHT_FRUEH'), 27.46, places=2)
|
||||
self.assertEqual(self._total(slip, 'NACHT_SV'), 0.0)
|
||||
|
||||
def test_09_att_nacht_sv(self):
|
||||
"""NAZ-Nachtarbeit (22–6): nur €-Sondervergütung, kein Zuschlag."""
|
||||
employee = self._employee(stufe=1)
|
||||
slip = self._compute(employee, 11, work_entries=[
|
||||
(self._wet('l10n_at_hr_payroll_private.wett_att_nacht'),
|
||||
date(2026, 11, 5), 4.0),
|
||||
])
|
||||
self.assertAlmostEqual(
|
||||
self._total(slip, 'NACHT_SV'), 4 * 2.62, places=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mehrarbeit (RF-Z5; 2748)
|
||||
# ------------------------------------------------------------------
|
||||
def test_10_mehrarbeit_band_2748(self):
|
||||
"""§ 4a: Basis ÷ 167, Zuschlag 50 % = 19,75 je Stunde."""
|
||||
employee = self._employee(kv=self.kv2748, gruppe=self.gruppe_2748_ii)
|
||||
slip = self._compute(employee, 11, work_entries=[
|
||||
(self._wet('l10n_at_hr_payroll_private.wett_mehr_vz'),
|
||||
date(2026, 11, 5), 1.0),
|
||||
])
|
||||
self.assertAlmostEqual(self._total(slip, 'MEHR_VZ'), 19.75, places=2)
|
||||
|
||||
def test_11_mehrarbeit_teilzeit_19d(self):
|
||||
"""§ 19d AZG: 25 % Kern-Parameter (keine KV-Abweichung geseedet)."""
|
||||
employee = self._employee(kv=self.kv2748, gruppe=self.gruppe_2748_ii)
|
||||
slip = self._compute(employee, 11, work_entries=[
|
||||
(self._wet('l10n_at_hr_payroll_private.wett_mehr_tz'),
|
||||
date(2026, 11, 5), 1.0),
|
||||
])
|
||||
self.assertAlmostEqual(self._total(slip, 'MEHR_TZ'), 16.46, places=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 13./14. Bezug (RF-B1…B7)
|
||||
# ------------------------------------------------------------------
|
||||
def test_12_b13_faellig_november_2203(self):
|
||||
"""sz13_monat=11: B13 im November, Basis Auszahlungsmonat."""
|
||||
employee = self._employee(stufe=1)
|
||||
slip = self._compute(employee, 11)
|
||||
self.assertAlmostEqual(self._total(slip, 'B13'), 2059.41, places=2)
|
||||
self.assertEqual(self._total(slip, 'B14'), 0.0)
|
||||
|
||||
def test_13_b13_november_basis_2748(self):
|
||||
"""2748: B13 im Dezember, Basis November-Gehalt (sz13_basis)."""
|
||||
employee = self._employee(kv=self.kv2748, gruppe=self.gruppe_2748_ii)
|
||||
slip = self._compute(employee, 12)
|
||||
self.assertAlmostEqual(self._total(slip, 'B13'), 2199.15, places=2)
|
||||
|
||||
def test_14_b13_aliquote_eintritt_kalendertage(self):
|
||||
"""RF-B4 (Kalendertage-Konvention): Eintritt 1. 4. → 275/365."""
|
||||
employee = self._employee(
|
||||
stufe=1, version_vals={'date_start': date(2026, 4, 1)})
|
||||
slip = self._compute(employee, 11)
|
||||
self.assertAlmostEqual(
|
||||
self._total(slip, 'B13'), 2059.41 * 275 / 365, places=2)
|
||||
|
||||
def test_15_b14_urlaubsfaellig(self):
|
||||
"""B14 bei Urlaubsantritt im Juli."""
|
||||
employee = self._employee(stufe=1)
|
||||
slip = self._compute(employee, 7, work_entries=[
|
||||
(self.wet_leave, date(2026, 7, d), 8.0)
|
||||
for d in (6, 7, 8, 9, 10, 13, 14, 15, 16, 17)
|
||||
])
|
||||
self.assertAlmostEqual(self._total(slip, 'B14'), 2059.41, places=2)
|
||||
|
||||
def test_16_b14_dezember_ohne_urlaub(self):
|
||||
"""Kein Urlaub im Jahr: B14 mit der Dezember-Abrechnung."""
|
||||
employee = self._employee(stufe=1)
|
||||
slip_jul = self._compute(employee, 7)
|
||||
self.assertEqual(self._total(slip_jul, 'B14'), 0.0)
|
||||
slip_dez = self._compute(employee, 12)
|
||||
self.assertAlmostEqual(
|
||||
self._total(slip_dez, 'B14'), 2059.41, places=2)
|
||||
|
||||
def test_17_b14_rueckverrechnung_bei_austritt(self):
|
||||
"""RF-B4: B14 im Jänner voll bezahlt, Austritt 31. 3. →
|
||||
Rückverrechnung 90/365; B13 aliquote Endabrechnung."""
|
||||
employee = self._employee(stufe=1)
|
||||
slip_jan = self._compute(employee, 1, work_entries=[
|
||||
(self.wet_leave, date(2026, 1, 12), 8.0),
|
||||
])
|
||||
self._remove_attendance(slip_jan, date(2026, 1, 12))
|
||||
slip_jan.action_payslip_done()
|
||||
self.assertAlmostEqual(self._total(slip_jan, 'B14'), 2059.41, places=2)
|
||||
employee.version_id.write({
|
||||
'date_end': date(2026, 3, 31),
|
||||
'contract_date_end': date(2026, 3, 31),
|
||||
})
|
||||
slip_mar = self._compute(employee, 3)
|
||||
faktor = 90 / 365.0
|
||||
self.assertAlmostEqual(
|
||||
self._total(slip_mar, 'B14_RUECK'),
|
||||
2059.41 * (faktor - 1.0), places=2)
|
||||
self.assertAlmostEqual(
|
||||
self._total(slip_mar, 'B13'), 2059.41 * faktor, places=2)
|
||||
self.assertEqual(self._total(slip_mar, 'B14'), 0.0)
|
||||
|
||||
def test_18_mischberechnung_beschaeftigungsausmass(self):
|
||||
"""RF-B5: 1. 1.–30. 6. 100 %, ab 1. 7. 50 % → Kalendertag-Mischung."""
|
||||
employee = self._employee(
|
||||
stufe=2,
|
||||
version_vals={'date_start': date(2026, 1, 1)},
|
||||
new_version_vals={
|
||||
'date_start': date(2026, 7, 1),
|
||||
'resource_calendar_id': self.calendar_teilzeit.id,
|
||||
})
|
||||
slip = self._compute(employee, 11)
|
||||
erwartet = (2179.68 * (181 / 365.0) * 1.0
|
||||
+ 2179.68 * (184 / 365.0) * 0.5)
|
||||
self.assertAlmostEqual(self._total(slip, 'B13'), erwartet, places=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Urlaubsentgelt — Ø regelmäßige Überstunden (RF-U2, 7-von-12)
|
||||
# ------------------------------------------------------------------
|
||||
def test_19_urlaub_ue_durchschnitt(self):
|
||||
employee = self._employee(stufe=1)
|
||||
for monat in (1, 2, 3, 4, 5, 6, 7, 8):
|
||||
self._compute(
|
||||
employee, monat,
|
||||
work_entries=[
|
||||
(self._wet('l10n_at_hr_payroll_private.wett_ue_std'),
|
||||
date(2026, monat, 5), 2.0),
|
||||
],
|
||||
confirm=True)
|
||||
slip = self._compute(employee, 12, work_entries=[
|
||||
(self.wet_leave, date(2026, 12, 7), 8.0),
|
||||
])
|
||||
avg = (8 * 2059.41 / 150.0 * 1.5 * 2) / 12.0
|
||||
self.assertAlmostEqual(
|
||||
self._total(slip, 'URL_UE'), avg * 1 / 23, places=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Netto / All-in
|
||||
# ------------------------------------------------------------------
|
||||
def test_20_netto_ohne_abzuege(self):
|
||||
"""R1: Netto = Brutto-Kategorien (AP3/AP4 folgen)."""
|
||||
employee = self._employee(stufe=1)
|
||||
slip = self._compute(employee, 11)
|
||||
brutto = sum(
|
||||
self._total(slip, code)
|
||||
for code in ('KVGEO', 'NACHT_SV', 'UE_STD', 'UE_NACHT',
|
||||
'UE_NACHT_FRUEH', 'UE_SOFEI', 'UE_SOFEI_NACHT',
|
||||
'MEHR_VZ', 'MEHR_VZ_NACHT', 'MEHR_TZ',
|
||||
'MEHR_TZ_NACHT', 'URL_UE', 'PRAM', 'B13', 'B14',
|
||||
'B14_RUECK'))
|
||||
self.assertAlmostEqual(self._total(slip, 'NETTO'), brutto, places=2)
|
||||
|
||||
def test_21_allin_deckungsrechnung(self):
|
||||
"""§ 2g AVRAG: Jahresdeckung = 12 × Pauschale vs. geschuldete
|
||||
ÜStd (minimale GP2-Umsetzung; UI-Bericht GP8)."""
|
||||
employee = self._employee(
|
||||
stufe=1,
|
||||
version_vals={'l10n_at_ue_pauschale_monatlich': 5.0,
|
||||
'l10n_at_grundlohn_ausweis': 2000.0})
|
||||
self._compute(employee, 1, work_entries=[
|
||||
(self._wet('l10n_at_hr_payroll_private.wett_ue_std'),
|
||||
date(2026, 1, 5), 2.0),
|
||||
], confirm=True)
|
||||
slip = self._compute(employee, 2, work_entries=[
|
||||
(self._wet('l10n_at_hr_payroll_private.wett_ue_std'),
|
||||
date(2026, 2, 5), 2.0),
|
||||
], confirm=True)
|
||||
deckung = slip._at_allin_deckung()
|
||||
self.assertAlmostEqual(deckung['pauschale'], 60.0, places=2)
|
||||
self.assertAlmostEqual(deckung['geschuldet'], 82.38, places=2)
|
||||
self.assertAlmostEqual(deckung['unterdeckung'], 22.38, places=2)
|
||||
Reference in New Issue
Block a user