From 67374611c6ef7f2dd21342fda5de9516e824e462 Mon Sep 17 00:00:00 2001 From: fegger Date: Wed, 9 Sep 2026 22:10:26 +0200 Subject: [PATCH] [ADD] l10n_at_hr_payroll_private: KV framework (GP1) GP1 of the approved General-AT implementation plan: the KV framework for the Austrian private economy on the Odoo 19 hr_payroll engine. - l10n.at.payroll.kv: collective agreement master data with chamber, union, validity date and a trace link to the KV library (personalverrechnung/quellen/kv/) - l10n.at.payroll.kv.wert: versioned KV parameters following the hr.rule.parameter.value pattern (Python literal, unique per KV/code/date); consumed via employee.kv_id._wert(code, date) - l10n.at.payroll.kv.gruppe / .stufe: Verwendungsgruppen with Erfahrungsstufen year tables, unique(gruppe, stufe, gueltig_ab), Stichtag lookup - hr.version / hr.employee contract fields: kv_id, kv_gruppe_id, erfahrungsstufe, ueberzahlung - import wizard for KV intake and yearly updates: table paste (Gruppe;Stufe;Gehalt[;Bezeichnung]), preview, >10% jump warning (warn lines never applied automatically), new gueltig_ab versions, historical values untouched - seed D2 (SI-2203 Handwerk und Gewerbe, SI-2748 Metallgewerbe): wage tables from 1. 1. 2026 curated from the KV library texts - 18 groups, 131 stage values, 27 parameters, including the special progression groups IV-M / V-OM (SI-2748) and the discontinued VG I (SI-2203) - tests: test_kv_katalog (seed lookups, Stichtag behaviour, versioning, parameters, wizard, contract fields) Statically validated (py_compile, XML well-formedness, CSV consistency, wizard logic simulation); runtime acceptance pending PostgreSQL on the dev host. --- addons/l10n_at_hr_payroll_private/README.md | 57 +++- addons/l10n_at_hr_payroll_private/__init__.py | 3 + .../__manifest__.py | 49 ++++ .../data/kv_seed.xml | 24 ++ .../data/l10n.at.payroll.kv.gruppe.csv | 19 ++ .../data/l10n.at.payroll.kv.gruppe.stufe.csv | 132 +++++++++ .../data/l10n.at.payroll.kv.wert.csv | 28 ++ .../models/__init__.py | 3 + .../models/hr_version_private.py | 58 ++++ .../models/kollektivvertrag.py | 225 +++++++++++++++ .../security/ir.model.access.csv | 11 + .../tests/__init__.py | 2 + .../tests/test_kv_katalog.py | 192 +++++++++++++ .../views/kv_views.xml | 270 ++++++++++++++++++ .../wizard/__init__.py | 2 + .../wizard/kv_katalog_import.py | 211 ++++++++++++++ 16 files changed, 1274 insertions(+), 12 deletions(-) create mode 100644 addons/l10n_at_hr_payroll_private/__init__.py create mode 100644 addons/l10n_at_hr_payroll_private/__manifest__.py create mode 100644 addons/l10n_at_hr_payroll_private/data/kv_seed.xml create mode 100644 addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.gruppe.csv create mode 100644 addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.gruppe.stufe.csv create mode 100644 addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.wert.csv create mode 100644 addons/l10n_at_hr_payroll_private/models/__init__.py create mode 100644 addons/l10n_at_hr_payroll_private/models/hr_version_private.py create mode 100644 addons/l10n_at_hr_payroll_private/models/kollektivvertrag.py create mode 100644 addons/l10n_at_hr_payroll_private/security/ir.model.access.csv create mode 100644 addons/l10n_at_hr_payroll_private/tests/__init__.py create mode 100644 addons/l10n_at_hr_payroll_private/tests/test_kv_katalog.py create mode 100644 addons/l10n_at_hr_payroll_private/views/kv_views.xml create mode 100644 addons/l10n_at_hr_payroll_private/wizard/__init__.py create mode 100644 addons/l10n_at_hr_payroll_private/wizard/kv_katalog_import.py diff --git a/addons/l10n_at_hr_payroll_private/README.md b/addons/l10n_at_hr_payroll_private/README.md index 1008700..ca83ad0 100644 --- a/addons/l10n_at_hr_payroll_private/README.md +++ b/addons/l10n_at_hr_payroll_private/README.md @@ -1,20 +1,53 @@ -# l10n_at_hr_payroll_private (Gerüst) +# l10n_at_hr_payroll_private (General-AT) General-AT-Payroll (KV-basierte Privatwirtschaft: KV-Gehaltskataloge, -AZG/UrlG-Arbeitszeit, 13./14. Bezüge, Abfertigung Neu). +AZG/UrlG-Arbeitszeit, 13./14. Bezüge, Abfertigung Neu) für Odoo 19. **Implementierungsplan (freigegeben, M0):** [`personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md`](../../personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md) +· verbindliche Rechtslage: +[`personalverrechnung/RECHTSQUELLEN-Privat.md`](../../personalverrechnung/RECHTSQUELLEN-Privat.md) -Umsetzung startet mit **M1 (GP0)**: `RECHTSQUELLEN-Privat.md`, -RIS-/findok-Verifikationen, Odoo-API-Neuverifikation, -Pilotmandant-Definition, UGB-Buchungsmatrix. Kein Manifest/Code -bis M2 (GP1). +**GP1 umgesetzt (KV-Framework):** -**Kern-Vorfinanzierung (D3):** AP3 (Lohnsteuer) und AP4 (SV/DB-Regeln) -werden im Kern-Modul `l10n_at_hr_payroll` auf General-AT-Kosten gebaut -und von beiden Produkten konsumiert (die GemBG-Structure erbt); -bei Bgld.-Auftragserteilung Anrechnung/Umlegung per Change-Request. +- `l10n.at.payroll.kv` — KV-Stammdaten mit Trace-Link zur KV-Library + (`library_variant_id` → `personalverrechnung/quellen/kv/kv-catalog.json`) + und Quellen-URL der Arbeitgeberkammer; +- `l10n.at.payroll.kv.wert` — versionierte KV-Eckwerte (Muster + `hr.rule.parameter.value`, KV-Scope): Regelkonsum über + `employee.kv_id._wert('code', payslip.date_to)`; +- `l10n.at.payroll.kv.gruppe` / `.stufe` — Verwendungs-/Lohngruppen mit + Erfahrungsstufen, Jahrestabellen je `gueltig_ab` + (unique(Gruppe, Stufe, Stichtag)); +- Vertragsfelder auf `hr.version` (durchgereicht auf `hr.employee`): + `kv_id`, `kv_gruppe_id`, `erfahrungsstufe`, `ueberzahlung`; +- Import-Wizard „KV-Katalog importieren": Tabellenpaste/CSV + (Gruppe;Stufe;Gehalt[;Bezeichnung]) mit Vorschau, Sprungwarnung + > 10 % (Warnzeilen werden nie automatisch übernommen) und Versionierung + über `gueltig_ab` — historische Werte bleiben unangetastet. -**Referenz-KVs R1 (D2):** SI-2203 Handwerk und Gewerbe + -SI-2748 Metallgewerbe (Feinjustierung je Pilotmandant in GP0). \ No newline at end of file +**Seed D2 (Referenz-KVs R1):** SI-2203 „Handwerk und Gewerbe" + +SI-2748 „Metallgewerbe", Gehaltstabellen ab 1. 1. 2026 kuratiert aus den +KV-Library-Volltexten (`quellen/kv/oegb/texts/`). Besonderheiten im Seed: + +- SI-2203: VG I ist ab 1. 1. 2026 aufgelassen (Vorrückung nach § 17 Abs 6); + Meistergruppen M I–M III als eigene Gruppen (M II ohne/mit Fachschule); +- SI-2748: eigene Vorrückungsverläufe als eigene Gruppen (IV-M = Meister + in VG IV bis „nach 17 VWGJ", V-OM = Obermeister bis „nach 12 VWGJ"); + Nacht-Sondervergütung 2,71 € (2026) bzw. 2,76 € (2027) als + Jahresversionen. + +**Kern-Vorfinanzierung (D3):** Lohnsteuer (AP3) und SV/DB-Regeln (AP4) +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. + +Tests: `test_kv_katalog` (Seed, Stichtags-Lookup, Versionierung, +Eckwerte, Wizard/Sprungwarnung, Vertragsfelder) — +`python3 odoo/odoo-bin -d -i l10n_at_hr_payroll_private +--test-tags /l10n_at_hr_payroll_private --test-enable --stop-after-init +--addons-path=odoo_enterprise/odoo/addons,addons` \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/__init__.py b/addons/l10n_at_hr_payroll_private/__init__.py new file mode 100644 index 0000000..b79704c --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/__init__.py @@ -0,0 +1,3 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +from . import models +from . import wizard \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/__manifest__.py b/addons/l10n_at_hr_payroll_private/__manifest__.py new file mode 100644 index 0000000..3aee38a --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/__manifest__.py @@ -0,0 +1,49 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +{ + 'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)', + 'version': '19.0.1.0.0', + 'author': 'ixsol / gem360', + 'website': 'https://gem360.at', + 'category': 'Human Resources/Payroll', + 'description': """ +General-AT-Personalverrechnung (KV-basierte Privatwirtschaft) +============================================================== + +Monatsverrechnung für Beschäftigte der österreichischen Privatwirtschaft +nach dem **KV-Regime** auf der Odoo-19 ``hr_payroll``-Engine. Fachliche +Grundlagen: ``personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md`` und +``personalverrechnung/RECHTSQUELLEN-Privat.md`` (GP0-Verifikationen). + +- **GP1 KV-Framework (dieser Stand):** Kollektivvertrags-Stammdaten + (``l10n.at.payroll.kv``) mit Trace-Link zur KV-Library, versionierte + KV-Eckwerte (``kv.wert`` — Muster ``hr.rule.parameter.value``, + Konsum über ``employee.kv_id._wert(code, datum)``), Verwendungs-/ + Lohngruppen mit Erfahrungsstufen (``kv.gruppe`` / ``kv.gruppe.stufe``, + Jahrestabellen je ``gueltig_ab``), KV-Einstufung am Vertrag + (``hr.version``: KV, Gruppe, Erfahrungsstufe, Überzahlung) sowie + ein Import-Wizard für KV-Aufnahme und Jahresupdate (Vorschau, + Sprungwarnung > 10 %, versionierte Werte — TASY-Muster). +- **Seed D2 (Referenz-KVs R1):** SI-2203 „Handwerk und Gewerbe" + + SI-2748 „Metallgewerbe" (Gehaltstabellen ab 1. 1. 2026, kuratiert aus + den KV-Library-Volltexten). +- **Folgen (GP2+):** Entgelt-Engine (KVGEO, AZG-Zuschläge, 13./14. + Bezüge), Lohnsteuer/SV im Bundes-Kern (D3-Vorfinanzierung), + Abfertigung Neu, Meldewesen/Belege, FI-Buchung UGB. + +Kein VRV-Bezug (Gemeinde-Schiene bleibt ``l10n_at_gemeinde_payroll`` + +gem360-Brücke). +""", + 'depends': ['l10n_at_hr_payroll', 'hr_payroll_account'], + 'data': [ + 'security/ir.model.access.csv', + 'data/kv_seed.xml', + 'data/l10n.at.payroll.kv.gruppe.csv', + 'data/l10n.at.payroll.kv.gruppe.stufe.csv', + 'data/l10n.at.payroll.kv.wert.csv', + 'views/kv_views.xml', + ], + 'countries': ['at'], + 'license': 'LGPL-3', + 'installable': True, + 'auto_install': False, +} \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/kv_seed.xml b/addons/l10n_at_hr_payroll_private/data/kv_seed.xml new file mode 100644 index 0000000..ebdcc19 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/kv_seed.xml @@ -0,0 +1,24 @@ + + + + + Handwerk und Gewerbe (Angestellte) + SI-2203 + wko + GPA + 2026-01-01 + SI-2203_de + https://www.wko.at/kollektivvertrag/kollektivvertrag-gewerbe-handwerk-und-dienstleistung-2026 + + + Metallgewerbe (Angestellte) + SI-2748 + wko + GPA + 2026-01-01 + SI-2748_de + https://www.wko.at/kollektivvertrag/metallgewerbe-kollektivvertrag-angestellte + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.gruppe.csv b/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.gruppe.csv new file mode 100644 index 0000000..80b9d29 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.gruppe.csv @@ -0,0 +1,19 @@ +id,kv_id:id,code,name,lohn_basis +kv2203_grp_ii,l10n_at_hr_payroll_private.kv_si_2203,II,Verwendungsgruppe II,monatlich +kv2203_grp_iii,l10n_at_hr_payroll_private.kv_si_2203,III,Verwendungsgruppe III,monatlich +kv2203_grp_iv,l10n_at_hr_payroll_private.kv_si_2203,IV,Verwendungsgruppe IV,monatlich +kv2203_grp_v,l10n_at_hr_payroll_private.kv_si_2203,V,Verwendungsgruppe V,monatlich +kv2203_grp_vi,l10n_at_hr_payroll_private.kv_si_2203,VI,Verwendungsgruppe VI,monatlich +kv2203_grp_m1,l10n_at_hr_payroll_private.kv_si_2203,M1,Meistergruppe M I (Hilfsmeister / Betriebsaufseher),monatlich +kv2203_grp_m2,l10n_at_hr_payroll_private.kv_si_2203,M2,Meistergruppe M II (Meister ohne Fachschule),monatlich +kv2203_grp_m2fs,l10n_at_hr_payroll_private.kv_si_2203,M2FS,Meistergruppe M II (Meister mit Fachschule),monatlich +kv2203_grp_m3,l10n_at_hr_payroll_private.kv_si_2203,M3,Meistergruppe M III (Obermeister),monatlich +kv2748_grp_i,l10n_at_hr_payroll_private.kv_si_2748,I,Verwendungsgruppe I,monatlich +kv2748_grp_ii,l10n_at_hr_payroll_private.kv_si_2748,II,Verwendungsgruppe II,monatlich +kv2748_grp_iii,l10n_at_hr_payroll_private.kv_si_2748,III,Verwendungsgruppe III,monatlich +kv2748_grp_iv,l10n_at_hr_payroll_private.kv_si_2748,IV,Verwendungsgruppe IV,monatlich +kv2748_grp_ivm,l10n_at_hr_payroll_private.kv_si_2748,IV-M,Verwendungsgruppe IV - Meister (eigener Vorrückungsverlauf),monatlich +kv2748_grp_v,l10n_at_hr_payroll_private.kv_si_2748,V,Verwendungsgruppe V,monatlich +kv2748_grp_vom,l10n_at_hr_payroll_private.kv_si_2748,V-OM,Verwendungsgruppe V - Obermeister (eigener Vorrückungsverlauf),monatlich +kv2748_grp_vi,l10n_at_hr_payroll_private.kv_si_2748,VI,Verwendungsgruppe VI,monatlich +kv2748_grp_meister,l10n_at_hr_payroll_private.kv_si_2748,MEISTER,Meistergruppe,monatlich \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.gruppe.stufe.csv b/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.gruppe.stufe.csv new file mode 100644 index 0000000..ddb8dc8 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.gruppe.stufe.csv @@ -0,0 +1,132 @@ +id,gruppe_id:id,stufe,bezeichnung,gehalt,gueltig_ab +kv2203_st_ii_1,l10n_at_hr_payroll_private.kv2203_grp_ii,1,im 1. u 2. VWGJ,2059.41,2026-01-01 +kv2203_st_ii_2,l10n_at_hr_payroll_private.kv2203_grp_ii,2,nach 2 VWGJ,2179.68,2026-01-01 +kv2203_st_ii_3,l10n_at_hr_payroll_private.kv2203_grp_ii,3,nach 4 VWGJ,2315.46,2026-01-01 +kv2203_st_ii_4,l10n_at_hr_payroll_private.kv2203_grp_ii,4,nach 6 VWGJ,2457.65,2026-01-01 +kv2203_st_ii_5,l10n_at_hr_payroll_private.kv2203_grp_ii,5,nach 8 VWGJ,2599.83,2026-01-01 +kv2203_st_ii_6,l10n_at_hr_payroll_private.kv2203_grp_ii,6,nach 10 VWGJ,2742.03,2026-01-01 +kv2203_st_ii_7,l10n_at_hr_payroll_private.kv2203_grp_ii,7,nach 12 VWGJ,2863.88,2026-01-01 +kv2203_st_ii_8,l10n_at_hr_payroll_private.kv2203_grp_ii,8,nach 15 VWGJ,3087.28,2026-01-01 +kv2203_st_iii_1,l10n_at_hr_payroll_private.kv2203_grp_iii,1,im 1. u 2. VWGJ,2528.04,2026-01-01 +kv2203_st_iii_2,l10n_at_hr_payroll_private.kv2203_grp_iii,2,nach 2 VWGJ,2705.03,2026-01-01 +kv2203_st_iii_3,l10n_at_hr_payroll_private.kv2203_grp_iii,3,nach 4 VWGJ,2881.97,2026-01-01 +kv2203_st_iii_4,l10n_at_hr_payroll_private.kv2203_grp_iii,4,nach 6 VWGJ,3058.96,2026-01-01 +kv2203_st_iii_5,l10n_at_hr_payroll_private.kv2203_grp_iii,5,nach 8 VWGJ,3232.73,2026-01-01 +kv2203_st_iii_6,l10n_at_hr_payroll_private.kv2203_grp_iii,6,nach 10 VWGJ,3409.53,2026-01-01 +kv2203_st_iii_7,l10n_at_hr_payroll_private.kv2203_grp_iii,7,nach 12 VWGJ,3561.04,2026-01-01 +kv2203_st_iii_8,l10n_at_hr_payroll_private.kv2203_grp_iii,8,nach 15 VWGJ,3838.85,2026-01-01 +kv2203_st_iv_1,l10n_at_hr_payroll_private.kv2203_grp_iv,1,im 1. u 2. VWGJ,3146.27,2026-01-01 +kv2203_st_iv_2,l10n_at_hr_payroll_private.kv2203_grp_iv,2,nach 2 VWGJ,3366.49,2026-01-01 +kv2203_st_iv_3,l10n_at_hr_payroll_private.kv2203_grp_iv,3,nach 4 VWGJ,3586.73,2026-01-01 +kv2203_st_iv_4,l10n_at_hr_payroll_private.kv2203_grp_iv,4,nach 6 VWGJ,3806.97,2026-01-01 +kv2203_st_iv_5,l10n_at_hr_payroll_private.kv2203_grp_iv,5,nach 8 VWGJ,4027.20,2026-01-01 +kv2203_st_iv_6,l10n_at_hr_payroll_private.kv2203_grp_iv,6,nach 10 VWGJ,4247.47,2026-01-01 +kv2203_st_iv_7,l10n_at_hr_payroll_private.kv2203_grp_iv,7,nach 12 VWGJ,4436.22,2026-01-01 +kv2203_st_iv_8,l10n_at_hr_payroll_private.kv2203_grp_iv,8,nach 15 VWGJ,4782.32,2026-01-01 +kv2203_st_v_1,l10n_at_hr_payroll_private.kv2203_grp_v,1,im 1. u 2. VWGJ,3919.67,2026-01-01 +kv2203_st_v_2,l10n_at_hr_payroll_private.kv2203_grp_v,2,nach 2 VWGJ,4194.02,2026-01-01 +kv2203_st_v_3,l10n_at_hr_payroll_private.kv2203_grp_v,3,nach 4 VWGJ,4468.41,2026-01-01 +kv2203_st_v_4,l10n_at_hr_payroll_private.kv2203_grp_v,4,nach 6 VWGJ,4742.77,2026-01-01 +kv2203_st_v_5,l10n_at_hr_payroll_private.kv2203_grp_v,5,nach 8 VWGJ,5017.15,2026-01-01 +kv2203_st_v_6,l10n_at_hr_payroll_private.kv2203_grp_v,6,nach 10 VWGJ,5291.57,2026-01-01 +kv2203_st_v_7,l10n_at_hr_payroll_private.kv2203_grp_v,7,nach 12 VWGJ,5526.70,2026-01-01 +kv2203_st_v_8,l10n_at_hr_payroll_private.kv2203_grp_v,8,nach 15 VWGJ,5957.86,2026-01-01 +kv2203_st_vi_1,l10n_at_hr_payroll_private.kv2203_grp_vi,1,im 1. u 2. VWGJ,5482.40,2026-01-01 +kv2203_st_vi_2,l10n_at_hr_payroll_private.kv2203_grp_vi,2,nach 2 VWGJ,6167.67,2026-01-01 +kv2203_st_vi_3,l10n_at_hr_payroll_private.kv2203_grp_vi,3,nach 5 VWGJ,6853.01,2026-01-01 +kv2203_st_m1_1,l10n_at_hr_payroll_private.kv2203_grp_m1,1,im 1. u 2. VWGJ,2427.72,2026-01-01 +kv2203_st_m1_2,l10n_at_hr_payroll_private.kv2203_grp_m1,2,nach 2 VWGJ,2427.72,2026-01-01 +kv2203_st_m1_3,l10n_at_hr_payroll_private.kv2203_grp_m1,3,nach 4 VWGJ,2586.57,2026-01-01 +kv2203_st_m1_4,l10n_at_hr_payroll_private.kv2203_grp_m1,4,nach 6 VWGJ,2745.39,2026-01-01 +kv2203_st_m1_5,l10n_at_hr_payroll_private.kv2203_grp_m1,5,nach 8 VWGJ,2904.19,2026-01-01 +kv2203_st_m1_6,l10n_at_hr_payroll_private.kv2203_grp_m1,6,nach 10 VWGJ,3063.05,2026-01-01 +kv2203_st_m1_7,l10n_at_hr_payroll_private.kv2203_grp_m1,7,nach 12 VWGJ,3199.16,2026-01-01 +kv2203_st_m1_8,l10n_at_hr_payroll_private.kv2203_grp_m1,8,nach 15 VWGJ,3448.74,2026-01-01 +kv2203_st_m2_1,l10n_at_hr_payroll_private.kv2203_grp_m2,1,im 1. u 2. VWGJ,3095.50,2026-01-01 +kv2203_st_m2_2,l10n_at_hr_payroll_private.kv2203_grp_m2,2,nach 2 VWGJ,3095.50,2026-01-01 +kv2203_st_m2_3,l10n_at_hr_payroll_private.kv2203_grp_m2,3,nach 4 VWGJ,3297.98,2026-01-01 +kv2203_st_m2_4,l10n_at_hr_payroll_private.kv2203_grp_m2,4,nach 6 VWGJ,3500.52,2026-01-01 +kv2203_st_m2_5,l10n_at_hr_payroll_private.kv2203_grp_m2,5,nach 8 VWGJ,3703.01,2026-01-01 +kv2203_st_m2_6,l10n_at_hr_payroll_private.kv2203_grp_m2,6,nach 10 VWGJ,3905.53,2026-01-01 +kv2203_st_m2_7,l10n_at_hr_payroll_private.kv2203_grp_m2,7,nach 12 VWGJ,4079.13,2026-01-01 +kv2203_st_m2_8,l10n_at_hr_payroll_private.kv2203_grp_m2,8,nach 15 VWGJ,4397.37,2026-01-01 +kv2203_st_m2fs_1,l10n_at_hr_payroll_private.kv2203_grp_m2fs,1,im 1. u 2. VWGJ,3241.68,2026-01-01 +kv2203_st_m2fs_2,l10n_at_hr_payroll_private.kv2203_grp_m2fs,2,nach 2 VWGJ,3241.68,2026-01-01 +kv2203_st_m2fs_3,l10n_at_hr_payroll_private.kv2203_grp_m2fs,3,nach 4 VWGJ,3453.74,2026-01-01 +kv2203_st_m2fs_4,l10n_at_hr_payroll_private.kv2203_grp_m2fs,4,nach 6 VWGJ,3665.79,2026-01-01 +kv2203_st_m2fs_5,l10n_at_hr_payroll_private.kv2203_grp_m2fs,5,nach 8 VWGJ,3877.83,2026-01-01 +kv2203_st_m2fs_6,l10n_at_hr_payroll_private.kv2203_grp_m2fs,6,nach 10 VWGJ,4089.90,2026-01-01 +kv2203_st_m2fs_7,l10n_at_hr_payroll_private.kv2203_grp_m2fs,7,nach 12 VWGJ,4271.68,2026-01-01 +kv2203_st_m2fs_8,l10n_at_hr_payroll_private.kv2203_grp_m2fs,8,nach 15 VWGJ,4605.01,2026-01-01 +kv2203_st_m3_1,l10n_at_hr_payroll_private.kv2203_grp_m3,1,im 1. u 2. VWGJ,3542.72,2026-01-01 +kv2203_st_m3_2,l10n_at_hr_payroll_private.kv2203_grp_m3,2,nach 2 VWGJ,3542.72,2026-01-01 +kv2203_st_m3_3,l10n_at_hr_payroll_private.kv2203_grp_m3,3,nach 4 VWGJ,3774.51,2026-01-01 +kv2203_st_m3_4,l10n_at_hr_payroll_private.kv2203_grp_m3,4,nach 6 VWGJ,4006.27,2026-01-01 +kv2203_st_m3_5,l10n_at_hr_payroll_private.kv2203_grp_m3,5,nach 8 VWGJ,4238.05,2026-01-01 +kv2203_st_m3_6,l10n_at_hr_payroll_private.kv2203_grp_m3,6,nach 10 VWGJ,4469.81,2026-01-01 +kv2203_st_m3_7,l10n_at_hr_payroll_private.kv2203_grp_m3,7,nach 12 VWGJ,4668.45,2026-01-01 +kv2203_st_m3_8,l10n_at_hr_payroll_private.kv2203_grp_m3,8,nach 15 VWGJ,5032.68,2026-01-01 +kv2748_st_i_1,l10n_at_hr_payroll_private.kv2748_grp_i,1,im 1. u 2. VWGJ,1960.54,2026-01-01 +kv2748_st_i_2,l10n_at_hr_payroll_private.kv2748_grp_i,2,nach 2 VWGJ,2058.43,2026-01-01 +kv2748_st_i_3,l10n_at_hr_payroll_private.kv2748_grp_i,3,nach 4 VWGJ,2190.76,2026-01-01 +kv2748_st_i_4,l10n_at_hr_payroll_private.kv2748_grp_i,4,nach 6 VWGJ,2323.12,2026-01-01 +kv2748_st_i_5,l10n_at_hr_payroll_private.kv2748_grp_i,5,nach 8 VWGJ,2455.45,2026-01-01 +kv2748_st_i_6,l10n_at_hr_payroll_private.kv2748_grp_i,6,nach 10 VWGJ,2588.72,2026-01-01 +kv2748_st_i_7,l10n_at_hr_payroll_private.kv2748_grp_i,7,nach 12 VWGJ,2703.75,2026-01-01 +kv2748_st_i_8,l10n_at_hr_payroll_private.kv2748_grp_i,8,nach 15 VWGJ,2914.69,2026-01-01 +kv2748_st_ii_1,l10n_at_hr_payroll_private.kv2748_grp_ii,1,im 1. u 2. VWGJ,2199.15,2026-01-01 +kv2748_st_ii_2,l10n_at_hr_payroll_private.kv2748_grp_ii,2,nach 2 VWGJ,2350.62,2026-01-01 +kv2748_st_ii_3,l10n_at_hr_payroll_private.kv2748_grp_ii,3,nach 4 VWGJ,2502.07,2026-01-01 +kv2748_st_ii_4,l10n_at_hr_payroll_private.kv2748_grp_ii,4,nach 6 VWGJ,2655.45,2026-01-01 +kv2748_st_ii_5,l10n_at_hr_payroll_private.kv2748_grp_ii,5,nach 8 VWGJ,2809.08,2026-01-01 +kv2748_st_ii_6,l10n_at_hr_payroll_private.kv2748_grp_ii,6,nach 10 VWGJ,2962.72,2026-01-01 +kv2748_st_ii_7,l10n_at_hr_payroll_private.kv2748_grp_ii,7,nach 12 VWGJ,3094.32,2026-01-01 +kv2748_st_ii_8,l10n_at_hr_payroll_private.kv2748_grp_ii,8,nach 15 VWGJ,3335.74,2026-01-01 +kv2748_st_iii_1,l10n_at_hr_payroll_private.kv2748_grp_iii,1,im 1. u 2. VWGJ,2727.40,2026-01-01 +kv2748_st_iii_2,l10n_at_hr_payroll_private.kv2748_grp_iii,2,nach 2 VWGJ,2918.34,2026-01-01 +kv2748_st_iii_3,l10n_at_hr_payroll_private.kv2748_grp_iii,3,nach 4 VWGJ,3109.21,2026-01-01 +kv2748_st_iii_4,l10n_at_hr_payroll_private.kv2748_grp_iii,4,nach 6 VWGJ,3300.16,2026-01-01 +kv2748_st_iii_5,l10n_at_hr_payroll_private.kv2748_grp_iii,5,nach 8 VWGJ,3491.06,2026-01-01 +kv2748_st_iii_6,l10n_at_hr_payroll_private.kv2748_grp_iii,6,nach 10 VWGJ,3682.00,2026-01-01 +kv2748_st_iii_7,l10n_at_hr_payroll_private.kv2748_grp_iii,7,nach 12 VWGJ,3845.61,2026-01-01 +kv2748_st_iii_8,l10n_at_hr_payroll_private.kv2748_grp_iii,8,nach 15 VWGJ,4145.62,2026-01-01 +kv2748_st_iv_1,l10n_at_hr_payroll_private.kv2748_grp_iv,1,im 1. u 2. VWGJ,3415.01,2026-01-01 +kv2748_st_iv_2,l10n_at_hr_payroll_private.kv2748_grp_iv,2,nach 2 VWGJ,3654.05,2026-01-01 +kv2748_st_iv_3,l10n_at_hr_payroll_private.kv2748_grp_iv,3,nach 4 VWGJ,3893.09,2026-01-01 +kv2748_st_iv_4,l10n_at_hr_payroll_private.kv2748_grp_iv,4,nach 6 VWGJ,4132.15,2026-01-01 +kv2748_st_iv_5,l10n_at_hr_payroll_private.kv2748_grp_iv,5,nach 8 VWGJ,4371.19,2026-01-01 +kv2748_st_iv_6,l10n_at_hr_payroll_private.kv2748_grp_iv,6,nach 10 VWGJ,4610.26,2026-01-01 +kv2748_st_iv_7,l10n_at_hr_payroll_private.kv2748_grp_iv,7,nach 12 VWGJ,4815.15,2026-01-01 +kv2748_st_iv_8,l10n_at_hr_payroll_private.kv2748_grp_iv,8,nach 15 VWGJ,5190.77,2026-01-01 +kv2748_st_ivm_1,l10n_at_hr_payroll_private.kv2748_grp_ivm,1,im 1.-4. VWGJ,3415.01,2026-01-01 +kv2748_st_ivm_2,l10n_at_hr_payroll_private.kv2748_grp_ivm,2,nach 4 VWGJ,3654.05,2026-01-01 +kv2748_st_ivm_3,l10n_at_hr_payroll_private.kv2748_grp_ivm,3,nach 6 VWGJ,3893.09,2026-01-01 +kv2748_st_ivm_4,l10n_at_hr_payroll_private.kv2748_grp_ivm,4,nach 8 VWGJ,4132.15,2026-01-01 +kv2748_st_ivm_5,l10n_at_hr_payroll_private.kv2748_grp_ivm,5,nach 10 VWGJ,4371.19,2026-01-01 +kv2748_st_ivm_6,l10n_at_hr_payroll_private.kv2748_grp_ivm,6,nach 12 VWGJ,4610.26,2026-01-01 +kv2748_st_ivm_7,l10n_at_hr_payroll_private.kv2748_grp_ivm,7,nach 14 VWGJ,4815.15,2026-01-01 +kv2748_st_ivm_8,l10n_at_hr_payroll_private.kv2748_grp_ivm,8,nach 17 VWGJ,5047.10,2026-01-01 +kv2748_st_v_1,l10n_at_hr_payroll_private.kv2748_grp_v,1,im 1. u 2. VWGJ,4299.70,2026-01-01 +kv2748_st_v_2,l10n_at_hr_payroll_private.kv2748_grp_v,2,nach 2 VWGJ,4594.04,2026-01-01 +kv2748_st_v_3,l10n_at_hr_payroll_private.kv2748_grp_v,3,nach 4 VWGJ,4888.37,2026-01-01 +kv2748_st_v_4,l10n_at_hr_payroll_private.kv2748_grp_v,4,nach 6 VWGJ,5182.71,2026-01-01 +kv2748_st_v_5,l10n_at_hr_payroll_private.kv2748_grp_v,5,nach 8 VWGJ,5477.00,2026-01-01 +kv2748_st_v_6,l10n_at_hr_payroll_private.kv2748_grp_v,6,nach 10 VWGJ,5771.34,2026-01-01 +kv2748_st_v_7,l10n_at_hr_payroll_private.kv2748_grp_v,7,nach 12 VWGJ,6023.65,2026-01-01 +kv2748_st_v_8,l10n_at_hr_payroll_private.kv2748_grp_v,8,nach 15 VWGJ,6486.15,2026-01-01 +kv2748_st_vom_1,l10n_at_hr_payroll_private.kv2748_grp_vom,1,im 1.-6. VWGJ,4299.70,2026-01-01 +kv2748_st_vom_2,l10n_at_hr_payroll_private.kv2748_grp_vom,2,nach 6 VWGJ,4594.04,2026-01-01 +kv2748_st_vom_3,l10n_at_hr_payroll_private.kv2748_grp_vom,3,nach 8 VWGJ,4888.37,2026-01-01 +kv2748_st_vom_4,l10n_at_hr_payroll_private.kv2748_grp_vom,4,nach 10 VWGJ,5182.71,2026-01-01 +kv2748_st_vom_5,l10n_at_hr_payroll_private.kv2748_grp_vom,5,nach 12 VWGJ,5477.00,2026-01-01 +kv2748_st_vi_1,l10n_at_hr_payroll_private.kv2748_grp_vi,1,im 1. u 2. VWGJ,6021.80,2026-01-01 +kv2748_st_vi_2,l10n_at_hr_payroll_private.kv2748_grp_vi,2,nach 2 VWGJ,6762.62,2026-01-01 +kv2748_st_vi_3,l10n_at_hr_payroll_private.kv2748_grp_vi,3,nach 5 VWGJ,7503.50,2026-01-01 +kv2748_st_meister_1,l10n_at_hr_payroll_private.kv2748_grp_meister,1,im 1. u 2. VWGJ,4130.76,2026-01-01 +kv2748_st_meister_2,l10n_at_hr_payroll_private.kv2748_grp_meister,2,nach 2 VWGJ,4130.76,2026-01-01 +kv2748_st_meister_3,l10n_at_hr_payroll_private.kv2748_grp_meister,3,nach 4 VWGJ,4303.13,2026-01-01 +kv2748_st_meister_4,l10n_at_hr_payroll_private.kv2748_grp_meister,4,nach 6 VWGJ,4475.48,2026-01-01 +kv2748_st_meister_5,l10n_at_hr_payroll_private.kv2748_grp_meister,5,nach 8 VWGJ,4647.84,2026-01-01 +kv2748_st_meister_6,l10n_at_hr_payroll_private.kv2748_grp_meister,6,nach 10 VWGJ,4820.17,2026-01-01 +kv2748_st_meister_7,l10n_at_hr_payroll_private.kv2748_grp_meister,7,nach 12 VWGJ,4992.53,2026-01-01 +kv2748_st_meister_8,l10n_at_hr_payroll_private.kv2748_grp_meister,8,nach 15 VWGJ,5270.29,2026-01-01 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.wert.csv b/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.wert.csv new file mode 100644 index 0000000..e03c849 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/l10n.at.payroll.kv.wert.csv @@ -0,0 +1,28 @@ +id,kv_id:id,code,name,parameter_value,date_from +kv2203_wert_naz,l10n_at_hr_payroll_private.kv_si_2203,naz,Normalarbeitszeit (Wochenstunden - § 4 Abs 1),40,2026-01-01 +kv2203_wert_teilungsfaktor_ue,l10n_at_hr_payroll_private.kv_si_2203,teilungsfaktor_ue,Teilungsfaktor Überstunde (1/x des Monatsgehalts - § 5 Abs 6),150,2026-01-01 +kv2203_wert_prorate,l10n_at_hr_payroll_private.kv_si_2203,prorate_divisor,Aliquoter Divisor Ein-/Austritt (§ 17 Abs 2),26,2026-01-01 +kv2203_wert_ue_tag,l10n_at_hr_payroll_private.kv_si_2203,ue_zuschlag_tag,Überstundenzuschlag Tag (%),50,2026-01-01 +kv2203_wert_ue_nacht,l10n_at_hr_payroll_private.kv_si_2203,ue_zuschlag_nacht,Überstundenzuschlag 20-6 Uhr (%),100,2026-01-01 +kv2203_wert_sofei,l10n_at_hr_payroll_private.kv_si_2203,so_feiertag_zuschlag,Zuschlag Sonn-/Feiertagsüberstunden (%),100,2026-01-01 +kv2203_wert_nacht_eur,l10n_at_hr_payroll_private.kv_si_2203,nacht_sonderverguetung,Sondervergütung Nachtarbeit 22-6 Uhr (EUR/h - § 6 Abs 1),2.62,2026-01-01 +kv2203_wert_sz13_monat,l10n_at_hr_payroll_private.kv_si_2203,sz13_monat,Auszahlmonat 13. Bezug (spätestens 30. 11.),11,2026-01-01 +kv2203_wert_sz14_faelligkeit,l10n_at_hr_payroll_private.kv_si_2203,sz14_faelligkeit,Fälligkeit 14. Bezug (§ 11 Abs 3),'urlaub',2026-01-01 +kv2203_wert_sz13_name,l10n_at_hr_payroll_private.kv_si_2203,sz13_name,Bezeichnung 13. Bezug,'Weihnachtsremuneration',2026-01-01 +kv2203_wert_sz14_name,l10n_at_hr_payroll_private.kv_si_2203,sz14_name,Bezeichnung 14. Bezug,'Urlaubszuschuss',2026-01-01 +kv2203_wert_ue_hoechster,l10n_at_hr_payroll_private.kv_si_2203,ue_hoechster_zuschlag,Bei Zuschlagskumulation nur der höchste Zuschlag (§ 5 Abs 7),True,2026-01-01 +kv2748_wert_naz,l10n_at_hr_payroll_private.kv_si_2748,naz,Normalarbeitszeit (Wochenstunden - § 4 Abs 1),38.5,2026-01-01 +kv2748_wert_teilungsfaktor_ue,l10n_at_hr_payroll_private.kv_si_2748,teilungsfaktor_ue,Teilungsfaktor Überstunde (1/x des Monatsgehalts - § 5 Abs 6),143,2026-01-01 +kv2748_wert_teilungsfaktor_norm,l10n_at_hr_payroll_private.kv_si_2748,teilungsfaktor_norm,Teilungsfaktor Normalarbeitsstunde/Mehrarbeit (§ 5 Abs 6),167,2026-01-01 +kv2748_wert_mehrarbeit,l10n_at_hr_payroll_private.kv_si_2748,mehrarbeit_zuschlag,Mehrarbeit Zuschlag § 4a (Stunden zwischen NAZ und 40),50,2026-01-01 +kv2748_wert_prorate,l10n_at_hr_payroll_private.kv_si_2748,prorate_divisor,Aliquoter Divisor Ein-/Austritt (§ 17 Abs 2),26,2026-01-01 +kv2748_wert_ue_tag,l10n_at_hr_payroll_private.kv_si_2748,ue_zuschlag_tag,Überstundenzuschlag Tag (%),50,2026-01-01 +kv2748_wert_ue_nacht,l10n_at_hr_payroll_private.kv_si_2748,ue_zuschlag_nacht,Überstundenzuschlag 20-6 Uhr bzw. 3.+ ÜStd nach 19 Uhr (%),100,2026-01-01 +kv2748_wert_sofei,l10n_at_hr_payroll_private.kv_si_2748,so_feiertag_zuschlag,Zuschlag Sonn-/Feiertagsüberstunden (%),100,2026-01-01 +kv2748_wert_nacht_eur,l10n_at_hr_payroll_private.kv_si_2748,nacht_sonderverguetung,Sondervergütung Nachtarbeit 22-6 Uhr (EUR/h - § 6),2.71,2026-01-01 +kv2748_wert_nacht_eur_2027,l10n_at_hr_payroll_private.kv_si_2748,nacht_sonderverguetung,Sondervergütung Nachtarbeit 22-6 Uhr (EUR/h - § 6),2.76,2027-01-01 +kv2748_wert_sz13_monat,l10n_at_hr_payroll_private.kv_si_2748,sz13_monat,Auszahlmonat 13. Bezug (spätestens 1. 12.),12,2026-01-01 +kv2748_wert_sz14_faelligkeit,l10n_at_hr_payroll_private.kv_si_2748,sz14_faelligkeit,Fälligkeit 14. Bezug (§ 11 Abs 3),'urlaub',2026-01-01 +kv2748_wert_sz13_name,l10n_at_hr_payroll_private.kv_si_2748,sz13_name,Bezeichnung 13. Bezug,'13. Monatsgehalt (Weihnachtsremuneration)',2026-01-01 +kv2748_wert_sz14_name,l10n_at_hr_payroll_private.kv_si_2748,sz14_name,Bezeichnung 14. Bezug,'14. Monatsgehalt (Urlaubszuschuss)',2026-01-01 +kv2748_wert_ue_hoechster,l10n_at_hr_payroll_private.kv_si_2748,ue_hoechster_zuschlag,Bei Zuschlagskumulation nur der höchste Zuschlag (§ 5 Abs 7),True,2026-01-01 \ 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 new file mode 100644 index 0000000..c095905 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/__init__.py @@ -0,0 +1,3 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +from . import kollektivvertrag +from . import hr_version_private \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/hr_version_private.py b/addons/l10n_at_hr_payroll_private/models/hr_version_private.py new file mode 100644 index 0000000..db52cbe --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/hr_version_private.py @@ -0,0 +1,58 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""KV-Einstufung am Arbeitsvertrag (hr.version) und Durchreichung auf +hr.employee — Muster ``hr_version_bgld.py`` des GemBG-Moduls. + +Die Stufenlogik der Referenz-KVs läuft über Verwendungsgruppenjahre +(VWGJ, Biennal-/Triennalsprünge, § 17 der Rahmen-KVs); die Vorrückung +selbst ist GP2-Thema (Wizard analog GemBG), hier werden die +Vertragsfelder und der Katalog-Lookup bereitgestellt. +""" +from odoo import fields, models + + +class HrVersion(models.Model): + _inherit = 'hr.version' + + kv_id = fields.Many2one( + 'l10n.at.payroll.kv', string='Kollektivvertrag', index=True, + groups="hr_payroll.group_hr_payroll_user", tracking=True) + kv_gruppe_id = fields.Many2one( + 'l10n.at.payroll.kv.gruppe', string='Verwendungsgruppe', index=True, + groups="hr_payroll.group_hr_payroll_user", tracking=True) + erfahrungsstufe = fields.Integer( + string='Erfahrungsstufe', + groups="hr_payroll.group_hr_payroll_user", tracking=True, + help="Stufenindex der KV-Gehaltstabelle (1 = unterste Stufe, z. B. " + "'im 1. u 2. Verwendungsgruppenjahr').") + ueberzahlung = fields.Monetary( + string='Überzahlung (individuelles Gehalt)', + currency_field='currency_id', + groups="hr_payroll.group_hr_payroll_user", tracking=True, + help="Individuell vereinbartes Monatsgehalt; überschreibt das " + "Kataloggehalt der Gruppe/Stufe (Günstigkeitsvergleich, " + "z. B. § 18 Anrechnung der Rahmen-KVs).") + currency_id = fields.Many2one( + 'res.currency', related='company_id.currency_id') + + def _get_kv_gehalt(self, datum=None): + """Katalog-Position (Monatsgehalt) der Version zum Stichtag.""" + self.ensure_one() + return self.kv_gruppe_id._get_gehalt(self.erfahrungsstufe, datum) + + +class HrEmployee(models.Model): + _inherit = 'hr.employee' + + kv_id = fields.Many2one( + related="version_id.kv_id", inherited=True, + readonly=False, groups="hr_payroll.group_hr_payroll_user") + kv_gruppe_id = fields.Many2one( + related="version_id.kv_gruppe_id", inherited=True, + readonly=False, groups="hr_payroll.group_hr_payroll_user") + erfahrungsstufe = fields.Integer( + related="version_id.erfahrungsstufe", inherited=True, + readonly=False, groups="hr_payroll.group_hr_payroll_user") + ueberzahlung = fields.Monetary( + related="version_id.ueberzahlung", inherited=True, + readonly=False, groups="hr_payroll.group_hr_payroll_user", + currency_field='currency_id') \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/kollektivvertrag.py b/addons/l10n_at_hr_payroll_private/models/kollektivvertrag.py new file mode 100644 index 0000000..186d63c --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/kollektivvertrag.py @@ -0,0 +1,225 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""KV-Katalogmodelle (GP1): Kollektivvertrag, versionierte KV-Eckwerte, +Verwendungs-/Lohngruppen mit Erfahrungsstufen. + +Datenbasis ist die KV-Library (``personalverrechnung/quellen/kv/``); +die Seed-Kataloge der Referenz-KVs SI-2203/SI-2748 (Plan-Entscheidung +D2) sind aus den Volltexten kuratiert. Eckwerte folgen dem Muster +``hr.rule.parameter.value`` (Python-Literal, Versionierung über den +Stichtag), sind aber KV-scoped und werden daher bewusst nicht über +``payslip._rule_parameter`` konsumiert, sondern über +``employee.kv_id._wert('code', datum)``. +""" +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.safe_eval import safe_eval + + +class L10nAtPayrollKv(models.Model): + """Kollektivvertrag als Entgelt-Rechtsquelle der Privatwirtschaft. + + Normative Wirkung über ArbVG § 12; Quellenpolitik der KV-Library: + Main Source = Arbeitgeberkammer, kollektivvertrag.at als + Crosscheck-Baseline. + """ + _name = 'l10n.at.payroll.kv' + _description = 'Kollektivvertrag (AT Privatwirtschaft)' + _order = 'name' + + name = fields.Char(required=True) + code = fields.Char( + required=True, index=True, + help="Kurzbezeichnung, üblicherweise die KV-Library-Variante " + "(z. B. SI-2203).") + arbeitgeberkammer = fields.Selection([ + ('wko', 'Wirtschaftskammer Österreich (WKO)'), + ('lwk', 'Landwirtschaftskammer'), + ('aerztekammer', 'Ärztekammer'), + ('apotheke', 'Apothekerkammer'), + ('rechtsanwaltskammer', 'Rechtsanwaltskammer'), + ('notariatskammer', 'Notariatskammer'), + ]) + gewerkschaft = fields.Char( + help="Verantwortliche Gewerkschaft(en), z. B. GPA; mehrere " + "kommagetrennt.") + gueltig_ab = fields.Date( + string='Fassung gültig ab', + help="Gültigkeit der hinterlegten KV-Fassung (Lohnrunde), z. B. " + "2026-01-01 für die Gehaltstabellen ab 1. 1. 2026.") + library_variant_id = fields.Char( + string='KV-Library-Variante', + help="Trace-Link: variant_id im Katalog " + "personalverrechnung/quellen/kv/kv-catalog.json.") + quelle_url = fields.Char( + string='Quelle (Arbeitgeberkammer)', + help="Veröffentlichung der zuständigen Arbeitgeberkammer " + "(Main Source gemäß KV-Library-Quellenarchitektur).") + active = fields.Boolean(default=True) + gruppe_ids = fields.One2many( + 'l10n.at.payroll.kv.gruppe', 'kv_id', string='Verwendungsgruppen') + wert_ids = fields.One2many( + 'l10n.at.payroll.kv.wert', 'kv_id', string='KV-Eckwerte') + + _unique_kv = models.Constraint( + 'unique (code)', + "Ein Kollektivvertrag existiert je Code nur einmal.") + + def _wert(self, code, datum=None, raise_if_not_found=True): + """Eckwert zum Stichtag (z. B. 'naz', 'sz13_monat'). + + Rückgabe wie hr.rule.parameter: Python-Literal aus + parameter_value (Zahl, String oder Boolean). + """ + self.ensure_one() + datum = datum or fields.Date.context_today(self) + wert = self.env['l10n.at.payroll.kv.wert'].search([ + ('kv_id', '=', self.id), + ('code', '=', code), + ('date_from', '<=', datum)], order='date_from desc', limit=1) + if wert: + return safe_eval(wert.parameter_value) + if raise_if_not_found: + raise UserError(_( + "Kein KV-Eckwert %(code)s für %(kv)s zum %(datum)s " + "gefunden.", code=code, kv=self.display_name, + datum=datum)) + return None + + @api.depends('name', 'code') + def _compute_display_name(self): + for kv in self: + kv.display_name = f"{kv.name} ({kv.code})" if kv.code else kv.name + + +class L10nAtPayrollKvWert(models.Model): + """Versionierter KV-Eckwert (Muster: hr.rule.parameter.value).""" + _name = 'l10n.at.payroll.kv.wert' + _description = 'KV-Eckwert (versioniert)' + _order = 'kv_id, code, date_from desc' + + kv_id = fields.Many2one( + 'l10n.at.payroll.kv', required=True, index=True, ondelete='cascade') + code = fields.Char( + required=True, index=True, + help="Code für den Regelkonsum, z. B. 'naz' " + "(Normalarbeitszeit), 'teilungsfaktor_ue', " + "'sz13_monat', 'sz14_faelligkeit'.") + name = fields.Char(string='Bezeichnung') + parameter_value = fields.Text( + required=True, + help="Python-Literal (Zahl, String oder Boolean) — Muster " + "hr.rule.parameter.value.") + date_from = fields.Date( + string='Gültig ab', required=True, index=True, + default=lambda self: fields.Date.today().replace(month=1, day=1)) + + _unique_kv_wert = models.Constraint( + 'unique (kv_id, code, date_from)', + "Je KV, Code und Stichtag gibt es nur einen Wert.") + + @api.constrains('parameter_value') + def _check_parameter_value(self): + for wert in self: + try: + safe_eval(wert.parameter_value) + except Exception: + raise UserError(_( + "Ungültiger KV-Eckwert für %(code)s: " + "Python-Literal (Zahl, 'String' oder Boolean) erwartet.", + code=wert.code)) + + +class L10nAtPayrollKvGruppe(models.Model): + """Verwendungs-/Lohngruppe eines Kollektivvertrages (z. B. VG III). + + Die Monatsgehälter je Erfahrungsstufe stehen auf + ``l10n.at.payroll.kv.gruppe.stufe`` (Jahreswerte, ein Wert je + Stichtag). Angestellte/Arbeiter-Unterschiede laufen über die + ``lohn_basis`` und die KV-Eckwerte, nicht über eigene Strukturen + (Plan 4.2). + """ + _name = 'l10n.at.payroll.kv.gruppe' + _description = 'KV-Verwendungs-/Lohngruppe' + _order = 'kv_id, code' + + kv_id = fields.Many2one( + 'l10n.at.payroll.kv', required=True, index=True, ondelete='cascade') + code = fields.Char(required=True, index=True) + name = fields.Char(string='Bezeichnung') + lohn_basis = fields.Selection([ + ('monatlich', 'Monatsgehalt (vollzeit)'), + ('wochenstunden', 'Wochenstunden-Basis (Arbeiter)'), + ], required=True, default='monatlich') + active = fields.Boolean(default=True) + stufe_ids = fields.One2many( + 'l10n.at.payroll.kv.gruppe.stufe', 'gruppe_id', + string='Erfahrungsstufen') + + _unique_kv_gruppe = models.Constraint( + 'unique (kv_id, code)', + "Je Kollektivvertrag existiert eine Gruppe nur einmal.") + + @api.depends('kv_id', 'code', 'name') + def _compute_display_name(self): + for gruppe in self: + gruppe.display_name = ( + f"{gruppe.name or gruppe.code} " + f"[{gruppe.kv_id.display_name}]") + + def _get_gehalt(self, stufe, datum=None): + """Stufen-Position (Monatsgehalt) zum Stichtag (oder leer).""" + self.ensure_one() + datum = datum or fields.Date.context_today(self) + return self.env['l10n.at.payroll.kv.gruppe.stufe'].search( + [('gruppe_id', '=', self.id), + ('stufe', '=', stufe), + ('gueltig_ab', '<=', datum)], + order='gueltig_ab desc', limit=1) + + def _max_stufe(self, datum=None): + """Höchste Stufe mit gültigem Monatsgehalt zum Stichtag.""" + self.ensure_one() + datum = datum or fields.Date.context_today(self) + stufe = self.env['l10n.at.payroll.kv.gruppe.stufe'].search( + [('gruppe_id', '=', self.id), + ('gueltig_ab', '<=', datum)], + order='stufe desc', limit=1) + return stufe.stufe or 0 + + +class L10nAtPayrollKvGruppeStufe(models.Model): + """Monatsgehalt einer KV-Gruppe in einer Erfahrungsstufe ab einem + Stichtag (Jahrestabellen aus der KV-Lohnrunde).""" + _name = 'l10n.at.payroll.kv.gruppe.stufe' + _description = 'KV-Monatsgehalt je Erfahrungsstufe' + _order = 'gruppe_id, stufe, gueltig_ab desc' + _rec_names_search = ['gruppe_id'] + + gruppe_id = fields.Many2one( + 'l10n.at.payroll.kv.gruppe', required=True, index=True, + ondelete='cascade') + stufe = fields.Integer(required=True, index=True) + bezeichnung = fields.Char( + help="Stufen-Label der KV-Tabelle, z. B. 'im 1. u 2. VWGJ' " + "oder 'nach 15 VWGJ'.") + gehalt = fields.Monetary(currency_field='currency_id') + currency_id = fields.Many2one( + 'res.currency', default=lambda self: self.env.company.currency_id) + gueltig_ab = fields.Date( + required=True, index=True, + default=lambda self: fields.Date.today().replace(month=1, day=1)) + + _unique_kv_stufe = models.Constraint( + 'unique (gruppe_id, stufe, gueltig_ab)', + "Je Gruppe und Stufe gibt es nur einen Wert je Stichtag.") + + @api.depends('gruppe_id', 'stufe', 'gehalt', 'gueltig_ab') + def _compute_display_name(self): + for stufe in self: + stufe.display_name = _( + "%(gruppe)s, Stufe %(stufe)s (%(betrag)s ab %(ab)s)", + gruppe=stufe.gruppe_id.code, + stufe=stufe.stufe, + betrag=stufe.gehalt, + ab=stufe.gueltig_ab, + ) \ 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 new file mode 100644 index 0000000..a338f95 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv @@ -0,0 +1,11 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_l10n_at_payroll_kv_user,L10n AT Payroll KV User,model_l10n_at_payroll_kv,hr_payroll.group_hr_payroll_user,1,0,0,0 +access_l10n_at_payroll_kv_manager,L10n AT Payroll KV Manager,model_l10n_at_payroll_kv,hr_payroll.group_hr_payroll_manager,1,1,1,1 +access_l10n_at_payroll_kv_wert_user,L10n AT Payroll KV-Wert User,model_l10n_at_payroll_kv_wert,hr_payroll.group_hr_payroll_user,1,0,0,0 +access_l10n_at_payroll_kv_wert_manager,L10n AT Payroll KV-Wert Manager,model_l10n_at_payroll_kv_wert,hr_payroll.group_hr_payroll_manager,1,1,1,1 +access_l10n_at_payroll_kv_gruppe_user,L10n AT Payroll KV-Gruppe User,model_l10n_at_payroll_kv_gruppe,hr_payroll.group_hr_payroll_user,1,0,0,0 +access_l10n_at_payroll_kv_gruppe_manager,L10n AT Payroll KV-Gruppe Manager,model_l10n_at_payroll_kv_gruppe,hr_payroll.group_hr_payroll_manager,1,1,1,1 +access_l10n_at_payroll_kv_gruppe_stufe_user,L10n AT Payroll KV-Gruppe-Stufe User,model_l10n_at_payroll_kv_gruppe_stufe,hr_payroll.group_hr_payroll_user,1,0,0,0 +access_l10n_at_payroll_kv_gruppe_stufe_manager,L10n AT Payroll KV-Gruppe-Stufe Manager,model_l10n_at_payroll_kv_gruppe_stufe,hr_payroll.group_hr_payroll_manager,1,1,1,1 +access_l10n_at_payroll_kv_import_wizard,L10n AT Payroll KV-Import Wizard,model_l10n_at_payroll_kv_import_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1 +access_l10n_at_payroll_kv_import_line,L10n AT Payroll KV-Import Line,model_l10n_at_payroll_kv_import_line,hr_payroll.group_hr_payroll_user,1,1,1,1 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/tests/__init__.py b/addons/l10n_at_hr_payroll_private/tests/__init__.py new file mode 100644 index 0000000..acdd003 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/__init__.py @@ -0,0 +1,2 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +from . import test_kv_katalog \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/tests/test_kv_katalog.py b/addons/l10n_at_hr_payroll_private/tests/test_kv_katalog.py new file mode 100644 index 0000000..1fda368 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_kv_katalog.py @@ -0,0 +1,192 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""GP1-Rechenfälle: Seed D2 (SI-2203/SI-2748), Stichtags-Lookup, +Versionierung, KV-Eckwerte, Import-Wizard (Sprungwarnung) und +Vertragsfelder (Implementierungsplan-Privat, Tests 6.1).""" +from datetime import date + +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class KvKatalogTest(TransactionCase): + """KV-Framework: Katalog, versionierte Eckwerte, Wizard, Vertrag.""" + + def test_01_seed_2203_values(self): + """Seed SI-2203: Gehaltstabelle ab 1. 1. 2026 (KV-Library-Volltext).""" + stichtag = date(2026, 6, 1) + grp_iii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_iii') + self.assertEqual(grp_iii._get_gehalt(1, stichtag).gehalt, 2528.04) + self.assertEqual(grp_iii._get_gehalt(3, stichtag).gehalt, 2881.97) + self.assertEqual(grp_iii._get_gehalt(8, stichtag).gehalt, 3838.85) + # VG VI endet bei Stufe 3 (nach 5 VWGJ) + grp_vi = self.env.ref('l10n_at_hr_payroll_private.kv2203_grp_vi') + self.assertEqual(grp_vi._max_stufe(stichtag), 3) + self.assertEqual(grp_vi._get_gehalt(3, stichtag).gehalt, 6853.01) + # Meistergruppe M II mit abgeschlossener Fachschule + m2fs = self.env.ref('l10n_at_hr_payroll_private.kv2203_grp_m2fs') + self.assertEqual(m2fs._get_gehalt(1, stichtag).gehalt, 3241.68) + # Stufen-Label der Tabelle bleibt erhalten + self.assertEqual( + grp_iii._get_gehalt(3, stichtag).bezeichnung, 'nach 4 VWGJ') + + def test_02_seed_2748_values(self): + """Seed SI-2748: Gehaltstabelle ab 1. 1. 2026 inkl. der + Sonder-Vorrückungsverläufe (Meister VG IV, Obermeister VG V).""" + stichtag = date(2026, 6, 1) + grp_i = self.env.ref('l10n_at_hr_payroll_private.kv2748_grp_i') + self.assertEqual(grp_i._get_gehalt(1, stichtag).gehalt, 1960.54) + grp_ivm = self.env.ref('l10n_at_hr_payroll_private.kv2748_grp_ivm') + self.assertEqual(grp_ivm._get_gehalt(8, stichtag).gehalt, 5047.10) + self.assertEqual( + grp_ivm._get_gehalt(8, stichtag).bezeichnung, 'nach 17 VWGJ') + grp_vom = self.env.ref('l10n_at_hr_payroll_private.kv2748_grp_vom') + self.assertEqual(grp_vom._get_gehalt(2, stichtag).gehalt, 4594.04) + meister = self.env.ref( + 'l10n_at_hr_payroll_private.kv2748_grp_meister') + self.assertEqual(meister._get_gehalt(8, stichtag).gehalt, 5270.29) + self.assertEqual(meister._max_stufe(stichtag), 8) + + def test_03_wert_lookup(self): + """KV-Eckwerte (Muster hr.rule.parameter.value): Konsum über + kv_id._wert(code, datum); Strings als Python-Literal.""" + kv2203 = self.env.ref('l10n_at_hr_payroll_private.kv_si_2203') + kv2748 = self.env.ref('l10n_at_hr_payroll_private.kv_si_2748') + stichtag = date(2026, 6, 1) + self.assertEqual(kv2203._wert('naz', stichtag), 40) + self.assertEqual(kv2203._wert('teilungsfaktor_ue', stichtag), 150) + self.assertEqual(kv2203._wert('sz13_monat', stichtag), 11) + self.assertEqual(kv2203._wert('sz14_faelligkeit', stichtag), 'urlaub') + self.assertTrue(kv2203._wert('ue_hoechster_zuschlag', stichtag)) + self.assertEqual(kv2748._wert('naz', stichtag), 38.5) + self.assertEqual(kv2748._wert('teilungsfaktor_norm', stichtag), 167) + self.assertEqual(kv2748._wert('mehrarbeit_zuschlag', stichtag), 50) + # Jahresversion: Nacht-Sondervergütung 2,71 € (2026) / 2,76 € (2027) + self.assertEqual( + kv2748._wert('nacht_sonderverguetung', stichtag), 2.71) + self.assertEqual(kv2748._wert( + 'nacht_sonderverguetung', date(2027, 6, 1)), 2.76) + + def test_04_lookup_before_gueltig_ab(self): + """Vor dem Stichtag gibt es keinen Wert (laut, nicht 0).""" + grp_iii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_iii') + self.assertFalse(grp_iii._get_gehalt(1, date(2025, 12, 31))) + kv2203 = self.env.ref('l10n_at_hr_payroll_private.kv_si_2203') + with self.assertRaises(UserError): + kv2203._wert('naz', date(2025, 12, 31)) + self.assertIsNone( + kv2203._wert('naz', date(2025, 12, 31), + raise_if_not_found=False)) + + def test_05_versioning(self): + """Jahresversionen: neuer Wert greift erst ab seinem Stichtag.""" + grp_iii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_iii') + self.env['l10n.at.payroll.kv.gruppe.stufe'].create({ + 'gruppe_id': grp_iii.id, + 'stufe': 1, + 'gehalt': 2600.00, + 'gueltig_ab': date(2027, 1, 1), + }) + self.assertEqual( + grp_iii._get_gehalt(1, date(2026, 12, 31)).gehalt, 2528.04) + self.assertEqual( + grp_iii._get_gehalt(1, date(2027, 1, 1)).gehalt, 2600.00) + + def test_06_wizard_import(self): + """Import schreibt neue gueltig_ab-Versionen (Jahresupdate) und + legt neue Gruppen an (Aufnahme); Sprünge > 10 % werden verwarnt + und nie automatisch übernommen (TASY-Muster).""" + kv = self.env.ref('l10n_at_hr_payroll_private.kv_si_2203') + wizard = self.env['l10n.at.payroll.kv.import.wizard'].create({ + 'kv_id': kv.id, + 'gueltig_ab': date(2027, 1, 1), + 'eingabe': ( + 'III;1;2900,00;nach 2 VWGJ\n' # +14,7 % -> Sprungwarnung + 'III;1;2600,00\n' # +2,8 % -> unauffällig + 'X1;1;2000,00\n' # neue Gruppe (Aufnahme) + ), + }) + wizard.action_parse() + self.assertEqual(len(wizard.line_ids), 3) + sprung = wizard.line_ids.filtered( + lambda line: line.neuer_wert == 2900.0) + self.assertFalse(sprung.uebernehmen) + self.assertIn('Sprung', sprung.warnung) + unauffaellig = wizard.line_ids.filtered( + lambda line: line.neuer_wert == 2600.0) + self.assertTrue(unauffaellig.uebernehmen) + self.assertFalse(unauffaellig.warnung) + aufnahme = wizard.line_ids.filtered( + lambda line: line.gruppe_code == 'X1') + self.assertTrue(aufnahme.uebernehmen) + self.assertIn('Neue Gruppe', aufnahme.warnung) + wizard.action_apply() + + grp_iii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_iii') + self.assertEqual( + grp_iii._get_gehalt(1, date(2027, 1, 1)).gehalt, 2600.00) + # historischer Wert bleibt unangetastet + self.assertEqual( + grp_iii._get_gehalt(1, date(2026, 12, 31)).gehalt, 2528.04) + # Warnzeile wurde nicht geschrieben + self.assertFalse( + grp_iii.stufe_ids.filtered(lambda s: s.gehalt == 2900.0)) + # neue Gruppe mit Stufe angelegt + grp_x1 = self.env['l10n.at.payroll.kv.gruppe'].search([ + ('kv_id', '=', kv.id), ('code', '=', 'X1')]) + self.assertTrue(grp_x1) + self.assertEqual( + grp_x1._get_gehalt(1, date(2027, 1, 1)).gehalt, 2000.00) + + def test_07_wizard_bestehende_version_aktualisieren(self): + """Erneuter Import zum selben Stichtag aktualisiert die + bestehende Version statt zu verdoppeln (unique-Constraint).""" + kv = self.env.ref('l10n_at_hr_payroll_private.kv_si_2203') + wizard = self.env['l10n.at.payroll.kv.import.wizard'].create({ + 'kv_id': kv.id, + 'gueltig_ab': date(2026, 1, 1), + 'eingabe': 'III;1;2600,00\n', + }) + wizard.action_parse() + line = wizard.line_ids[0] + self.assertTrue(line.uebernehmen) + self.assertIn('aktualisiert', line.warnung) + self.assertEqual(line.alter_wert, 2528.04) + wizard.action_apply() + grp_iii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_iii') + self.assertEqual(len(grp_iii.stufe_ids.filtered( + lambda s: s.stufe == 1)), 1) + self.assertEqual( + grp_iii._get_gehalt(1, date(2026, 6, 1)).gehalt, 2600.00) + + def test_08_vertragsfelder(self): + """KV-Einstufung am Vertrag (hr.version) und Durchreichung auf + hr.employee; Katalog-Lookup über die Version.""" + employee = self.env['hr.employee'].create({'name': 'Test KV'}) + 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(2026, 1, 1), + }) + employee.write({ + 'kv_id': + self.env.ref('l10n_at_hr_payroll_private.kv_si_2203').id, + 'kv_gruppe_id': + self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_iii').id, + 'erfahrungsstufe': 3, + 'ueberzahlung': 3000.00, + }) + self.assertEqual(version.kv_id.code, 'SI-2203') + self.assertEqual(version.kv_gruppe_id.code, 'III') + self.assertEqual(version.erfahrungsstufe, 3) + self.assertEqual(version.ueberzahlung, 3000.00) + self.assertEqual( + version._get_kv_gehalt(date(2026, 6, 1)).gehalt, 2881.97) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/views/kv_views.xml b/addons/l10n_at_hr_payroll_private/views/kv_views.xml new file mode 100644 index 0000000..21dcf65 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/views/kv_views.xml @@ -0,0 +1,270 @@ + + + + l10n.at.payroll.kv.list + l10n.at.payroll.kv + + + + + + + + + + + + + + l10n.at.payroll.kv.form + l10n.at.payroll.kv + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + l10n.at.payroll.kv.gruppe.form + l10n.at.payroll.kv.gruppe + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + l10n.at.payroll.kv.gruppe.list + l10n.at.payroll.kv.gruppe + + + + + + + + + + + + l10n.at.payroll.kv.wert.list + l10n.at.payroll.kv.wert + + + + + + + + + + + + + l10n.at.payroll.kv.gruppe.stufe.list + l10n.at.payroll.kv.gruppe.stufe + + + + + + + + + + + + + Kollektivverträge + l10n.at.payroll.kv + list,form + + + + KV-Verwendungsgruppen + l10n.at.payroll.kv.gruppe + list,form + + + + KV-Eckwerte + l10n.at.payroll.kv.wert + list + {'search_default_groupby_kv_id': 1} + + + + KV-Monatsgehälter (Stufen) + l10n.at.payroll.kv.gruppe.stufe + list + {'search_default_groupby_gruppe_id': 1} + + + + + hr.employee.form.kv + hr.employee + + + + + + + + + + + + + + + + l10n.at.payroll.kv.import.wizard.form + l10n.at.payroll.kv.import.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + KV-Katalog importieren + l10n.at.payroll.kv.import.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 new file mode 100644 index 0000000..0397378 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/wizard/__init__.py @@ -0,0 +1,2 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +from . import kv_katalog_import \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/wizard/kv_katalog_import.py b/addons/l10n_at_hr_payroll_private/wizard/kv_katalog_import.py new file mode 100644 index 0000000..1f1ffc5 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/wizard/kv_katalog_import.py @@ -0,0 +1,211 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""KV-Katalog-Import (GP1): Neuaufnahme und Jahresupdate. + +Tabellenpaste/CSV (Gruppe;Stufe;Gehalt[;Bezeichnung]) wird geparst, +gegen den bestehenden Katalog plausibilisiert (Sprungwarnung > 10 % +nach TASY-Muster; Warnzeilen werden nie automatisch übernommen) und +schreibt nach expliziter Bestätigung **neue ``gueltig_ab``-Versionen** +— historische Werte bleiben unangetastet. Quelle der Tabellen sind die +KV-Library-Volltexte bzw. Kammer-Lohntafeln (Quellenarchitektur des +RUNBOOK). +""" +import re + +from odoo import _, fields, models + +_BETRAG_MUSTER = re.compile(r'^\d{1,3}(?:\.\d{3})+(?:,\d+)?$') + + +class L10nAtPayrollKvImportLine(models.TransientModel): + _name = 'l10n.at.payroll.kv.import.line' + _description = 'KV-Katalog-Import: Vorschauzeile' + + wizard_id = fields.Many2one( + 'l10n.at.payroll.kv.import.wizard', required=True, ondelete='cascade') + gruppe_code = fields.Char(string='Gruppe', required=True) + gruppe_name = fields.Char(string='Gruppenbezeichnung', readonly=True) + stufe = fields.Integer(required=True) + bezeichnung = fields.Char( + string='Stufen-Label', + help="Optionales Stufen-Label der KV-Tabelle, z. B. 'nach 2 VWGJ'.") + alter_wert = fields.Monetary(string='Alter Wert', readonly=True) + neuer_wert = fields.Monetary(string='Neuer Wert', required=True) + currency_id = fields.Many2one(related='wizard_id.currency_id') + uebernehmen = fields.Boolean(default=True) + warnung = fields.Char( + help="Leer = unauffällig; sonst Plausibilitätshinweis. " + "Warnzeilen (Sprung > 10 %) werden nie automatisch " + "übernommen.") + + +class L10nAtPayrollKvImportWizard(models.TransientModel): + _name = 'l10n.at.payroll.kv.import.wizard' + _description = 'KV-Katalog importieren (Aufnahme/Jahresupdate)' + + kv_id = fields.Many2one( + 'l10n.at.payroll.kv', string='Kollektivvertrag', required=True) + gueltig_ab = fields.Date( + string='Gültig ab', required=True, + default=lambda self: fields.Date.today().replace(month=1, day=1), + help="Stichtag der neuen Version (z. B. 1. 1. des Jahresgehalts " + "der KV-Runde). Bestehende Werte zu diesem Stichtag werden " + "aktualisiert, historische nie verändert.") + eingabe = fields.Text( + string='Tabellen (CSV)', + help="Eine Zeile je Stufenwert — Semikolon- oder " + "Tabulator-getrennt (Dezimalkomma erlaubt):\n" + "Gruppe;Stufe;Gehalt[;Bezeichnung]\n" + "z. B. III;1;2528,04;im 1. u 2. VWGJ") + line_ids = fields.One2many( + 'l10n.at.payroll.kv.import.line', 'wizard_id', string='Vorschau') + protokoll = fields.Text(readonly=True) + currency_id = fields.Many2one( + 'res.currency', default=lambda self: self.env.company.currency_id) + + # ------------------------------------------------------------------ + # Parsing + # ------------------------------------------------------------------ + + @staticmethod + def _parse_betrag(text): + """'2.059,41 €' / '2528.04' / '2 059,41' → float (oder None).""" + text = (text or '').strip().replace('€', '').replace('EUR', '') + text = re.sub(r'\s', '', text) + if not text: + return None + if _BETRAG_MUSTER.match(text): + # Österreichische Schreibweise: Punkt = Tausender + text = text.replace('.', '').replace(',', '.') + else: + text = text.replace(',', '.') + try: + return float(text) + except ValueError: + return None + + def _trenner(self, zeile): + if ';' in zeile: + return ';' + if '\t' in zeile: + return '\t' + return ',' + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def action_parse(self): + """Tabellen parsen und Vorschau (inkl. Plausibilität) aufbauen.""" + self.ensure_one() + stufen_obj = self.env['l10n.at.payroll.kv.gruppe.stufe'] + vortag = fields.Date.subtract(self.gueltig_ab, days=1) + line_vals, protokoll = [], [] + for roh in (self.eingabe or '').splitlines(): + zeile = roh.strip() + if not zeile or zeile.startswith('#'): + continue + teile = [t.strip() for t in zeile.split(self._trenner(zeile))] + if len(teile) < 3: + protokoll.append( + _('IGNORIERT (zu kurz): %(zeile)s', zeile=zeile)) + continue + gruppe_code, stufe_text, betrag_text = teile[:3] + bezeichnung = teile[3] if len(teile) > 3 else '' + try: + stufe = int(stufe_text) + except ValueError: + protokoll.append( + _('IGNORIERT (Stufe keine Zahl): %(zeile)s', + zeile=zeile)) + continue + betrag = self._parse_betrag(betrag_text) + if betrag is None: + protokoll.append( + _('IGNORIERT (Betrag unlesbar): %(zeile)s', + zeile=zeile)) + continue + + gruppe = self.env['l10n.at.payroll.kv.gruppe'].search([ + ('kv_id', '=', self.kv_id.id), + ('code', '=', gruppe_code)], limit=1) + warnung = '' + alter_wert = 0.0 + if not gruppe: + warnung = _('Neue Gruppe — wird mit der Übernahme angelegt.') + else: + bestehend = stufen_obj.search([ + ('gruppe_id', '=', gruppe.id), + ('stufe', '=', stufe), + ('gueltig_ab', '=', self.gueltig_ab)], limit=1) + if bestehend: + # Version zum Stichtag existiert schon: deren Wert + # wird aktualisiert — Referenz für die Plausibilität. + alter_wert = bestehend.gehalt + warnung = _( + 'Bestehende Version vom %(ab)s wird aktualisiert.', + ab=self.gueltig_ab) + else: + alt = gruppe._get_gehalt(stufe, vortag) + alter_wert = alt.gehalt if alt else 0.0 + if alter_wert and abs( + betrag - alter_wert) / alter_wert > 0.10: + warnung += ' ' + _( + 'Sprung > 10 %% (%(alt)s → %(neu)s)', + alt=alter_wert, neu=betrag) + + line_vals.append((0, 0, { + 'gruppe_code': gruppe_code, + 'gruppe_name': gruppe.name or '', + 'stufe': stufe, + 'bezeichnung': bezeichnung, + 'alter_wert': alter_wert, + 'neuer_wert': betrag, + 'uebernehmen': 'Sprung' not in warnung, + 'warnung': warnung, + })) + self.line_ids = [(5, 0, 0)] + line_vals + self.protokoll = '\n'.join(protokoll) + return True + + def action_apply(self): + """Ausgewählte Zeilen als neue ``gueltig_ab``-Versionen schreiben.""" + self.ensure_one() + gruppen_obj = self.env['l10n.at.payroll.kv.gruppe'] + stufen_obj = self.env['l10n.at.payroll.kv.gruppe.stufe'] + geschrieben = 0 + for line in self.line_ids.filtered('uebernehmen'): + gruppe = gruppen_obj.search([ + ('kv_id', '=', self.kv_id.id), + ('code', '=', line.gruppe_code)], limit=1) + if not gruppe: + gruppe = gruppen_obj.create({ + 'kv_id': self.kv_id.id, + 'code': line.gruppe_code, + 'name': line.gruppe_name or line.gruppe_code, + }) + stufe = stufen_obj.search([ + ('gruppe_id', '=', gruppe.id), + ('stufe', '=', line.stufe), + ('gueltig_ab', '=', self.gueltig_ab)], limit=1) + werte = { + 'gehalt': line.neuer_wert, + 'bezeichnung': line.bezeichnung, + } + if stufe: + stufe.write(werte) + else: + stufen_obj.create(dict(werte, gruppe_id=gruppe.id, + stufe=line.stufe, + gueltig_ab=self.gueltig_ab)) + geschrieben += 1 + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'success', + 'message': _('%(count)s Stufenwert(e) geschrieben ' + '(gültig ab %(ab)s).', + count=geschrieben, ab=self.gueltig_ab), + 'next': {'type': 'ir.actions.act_window_close'}, + }, + } \ No newline at end of file