From 27654211fb59d12e2b7994cdc4971d7eed28f43e Mon Sep 17 00:00:00 2001 From: fegger Date: Sat, 12 Sep 2026 12:58:53 +0200 Subject: [PATCH] [IMP] l10n_at_hr_payroll_private: GP6 FI-Buchung UGB (KFS/BW-6-Matrix, EKR-Debit-Split, NET-Konvention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buchungsmatrix nach dem Oesterreichischen Einheitskontenrahmen (KFS/BW 6; Nutzer-Referenz-Exemplar .additional_docs/KFSBW6_13072017_RF.pdf): Hook _configure_payroll_account_at (models/account_chart_private.py) ordnet Regelkonten, Journal SLR (Default = Gehaelterkonto 6200, BE-Muster) und das NET-Konto 3850 "Verbindlichkeiten aus Lohn und Gehalt" (Nutzer-Entscheidung 5.1; Anlage + reconcile, l10n_at-Standardchart hat es nicht) je Company zu — getriggert beim Chart-Load (_post_load_data) UND ueber die -Datendatei data/hr_payroll_account_private.xml fuer Bestands-Companies (erster einer function ist die Record-ID-Liste, convert.py-Semantik), idempotent. Konto-Suche exakt zuerst, Praefix-Fallback nur bis eine Stelle laenger — verhindert Matches auf Odoo-Scaffold-Konten chartloser Firmen (6000 darf nicht 600000 "Expenses" matchen). EKR-Debit-Split Arbeiter/Angestellte (Nutzer-Klaerung: 600-619 Loehne, 620-639 Gehaelter — "6000 Gehaelter/6200 Loehne" war gegen die eigene KFS/BW-6-Referenz vertauscht): l10n_at_personalgruppe am Vertrag (models/hr_version_private.py) + Override-Feld l10n_at_account_debit_arbeiter je Regel; Option A minimal (Nutzer-Entscheidung 5.2: Grundentgelt 6000, Sozialaufwand 6500, Abgaben 6600/6601/6602; Zuschlaege/UeStd/Praemien/SZ und BV (EKR 644 ohne Split) buchen fuer Arbeiter bis zur Referenzbuchhaltungs-Fixierung auf die Angestellten-Konten — fehlende Konten warnen, keine Erfindung). models/account_posting_private.py: Gesamtueberschreibung von _prepare_slip_lines nach Upstream-Muster (Odoo-Pin 19.0+e.20260910) mit einzeiliger Kontosubstitution — Odoo bietet dafuer keinen Override-Punkt; Verhalten testgepinnt. Matrix: KVGEO/URL_UE 6200/6000, Zuschlaege 6225, UeStd 6310, Praemien 6230, B13/B14 6240, DN-SV/LSt auf 3600/3540, NET 3850, SVDG-Block 6560/6500 auf 3600, BVG 6440 (Fallback 6400) auf Credit 3600 (Abfuhr via KV-Traeger, Nutzer 5.3), FLAFDB 6661/6601 auf 3541, KOMMST 6660/6600 auf 3550, DZ 6662/6602 auf 3542. NET-Umbenennung NETTO -> NET (Odoo-Konvention: action_register_payment, net_wage und Negativ-Netto-Erkennung filtern code == NET) inkl. 4 Bestands-Suiten. Odoo-19-Fund: das Anlegen einer Structure kopiert die Regeln des hr_payroll.default_structure (BASIC/GROSS/NET/...) via Feld-Default in rule_ids — die kopierte Default-NET-Regel kollidierte im result_rules-Dict (Netto fiel auf 0); Fix: in salary_rules_private.xml archiviert die Kopie (active=False; KEIN Unlink — struct_id ist NOT NULL und historische Lohnkontolinien behalten ihre Regel-Referenz; One2many blendet Archivierte aus). Journal-Default-Konten duerfen nicht Receivable/Payable sein (Odoo-Constraint); Rundungscents bucht Odoo als Adjustment Entry aufs Journal-Default. depends: + l10n_at, hr_payroll_account_iso20022 (SEPA-Auszahlung, Plan 4.5; kein VRV/MVAG — gem360-Bruecke). Tests: test_account_private (4: RF-BUCH-1..4 — Kontenplan-Mock ohne Chart-Install, Move-Balance, Kontenzeilen je Matrix, Arbeiter-Split, Geringfuegiger, Fallback/Warnung) — gesamt 88/88 gruen auf Wegwerf-DB (frischer -i-Lauf). Rechenfaelle docs/rechenfaelle/buchhaltung.md. 19.0.6.0.0. --- addons/l10n_at_hr_payroll_private/README.md | 41 ++- .../__manifest__.py | 30 +- .../data/hr_payroll_account_private.xml | 20 ++ .../data/salary_rules_private.xml | 19 +- .../docs/rechenfaelle/buchhaltung.md | 90 ++++++ .../models/__init__.py | 4 +- .../models/account_chart_private.py | 238 +++++++++++++++ .../models/account_posting_private.py | 89 ++++++ .../models/hr_version_private.py | 16 +- .../models/payslip_private.py | 26 ++ .../tests/__init__.py | 3 +- .../tests/test_account_private.py | 279 ++++++++++++++++++ .../tests/test_bv_private.py | 4 +- .../tests/test_entgelt_private.py | 2 +- .../tests/test_lst_private.py | 2 +- .../tests/test_sv_private.py | 4 +- 16 files changed, 842 insertions(+), 25 deletions(-) create mode 100644 addons/l10n_at_hr_payroll_private/data/hr_payroll_account_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/docs/rechenfaelle/buchhaltung.md create mode 100644 addons/l10n_at_hr_payroll_private/models/account_chart_private.py create mode 100644 addons/l10n_at_hr_payroll_private/models/account_posting_private.py create mode 100644 addons/l10n_at_hr_payroll_private/tests/test_account_private.py diff --git a/addons/l10n_at_hr_payroll_private/README.md b/addons/l10n_at_hr_payroll_private/README.md index b6c3617..c3a8a10 100644 --- a/addons/l10n_at_hr_payroll_private/README.md +++ b/addons/l10n_at_hr_payroll_private/README.md @@ -95,16 +95,41 @@ iVm § 49 ASVG — kein HBG-/Jahresdeckel, ohne §-67-Abs-3/6-Zeilen gemäß Rechenfälle `docs/rechenfaelle/abfertigung.md`. -**Offen (GP6+):** Meldewesen/Belege (GP7), FI-Buchung UGB (GP6), -Jahreswechsel (GP8); All-in-UI-Bericht (GP8); B14 längster-Urlaubs-Teil- -Verfeinerung; DZ-WKO-Verifikation (8.11) vor Wien-Mandanten; -§-7-BMSVG-Fiktivfälle (Präsenzdienst/Kranken-/Wochengeld) als -dokumentierte Option (Rechenfälle-Datei). +**GP6 FI-Buchung UGB (umgesetzt 2026-09-12):** Buchungsmatrix nach dem +Österreichischen Einheitskontenrahmen (KFS/BW 6): Hook +`_configure_payroll_account_at` (Chart-Load + ``-Datendatei, +idempotent; Konto-Suche exakt mit Längen-gebundenem Präfix-Fallback — +kein Match auf Odoo-Scaffold-Konten) ordnet Regelkonten, Journal SLR +(Default = Gehälterkonto, BE-Muster) und das NET-Konto **3850 +„Verbindlichkeiten aus Lohn und Gehalt"** (Anlage + reconcile) zu. +**EKR-Debit-Split Arbeiter/Angestellte:** `l10n_at_personalgruppe` am +Vertrag + Override-Feld `l10n_at_account_debit_arbeiter` je Regel +(Option A minimal: Grundentgelt 6000, Sozialaufwand 6500, Abgaben +6600/6601/6602; Zuschläge/ÜStd/SZ/Prämien buchen bis zur +Referenzbuchhaltungs-Fixierung auf die Angestellten-Konten); +Gesamtüberschreibung von `_prepare_slip_lines` (Odoo bietet keinen +Override-Punkt; durch Tests gepinnt). Netto-Regel auf Odoo-Konvention +**NET** umbenannt + die automatisch kopierte Default-NET-Regel wird +archiviert (Code-Kollision im result_rules-Dict; archiviert statt +unlinkt — historische Linien behalten die Referenz). +Auszahlung über `hr_payroll_account_iso20022`; kein VRV/MVAG +(gem360-Brücke). + +Rechenfälle `docs/rechenfaelle/buchhaltung.md`. + +**Offen (GP7+):** Meldewesen/Belege (GP7), Jahreswechsel (GP8); +All-in-UI-Bericht (GP8); B14 längster-Urlaubs-Teil-Verfeinerung; +DZ-WKO-Verifikation (8.11) vor Wien-Mandanten; §-7-BMSVG-Fiktivfälle +(Präsenzdienst/Kranken-/Wochengeld) als dokumentierte Option +(Rechenfälle-Datei); Arbeiter-Sub-Konten (Option A-Rest) mit der +Referenzbuchhaltung fixieren (GP0-Rest). Tests: `test_kv_katalog` + `test_entgelt_private` (GP2) + `test_lst_private` (GP3) + `test_sv_private` (GP4) + -`test_bv_private` (GP5) — -`.venv/bin/python -m odoo -d -u l10n_at_hr_payroll,l10n_at_hr_payroll_private +`test_bv_private` (GP5) + `test_account_private` (GP6) — +`.venv/bin/python -m odoo -d -i l10n_at_hr_payroll,l10n_at_hr_payroll_private --test-tags /l10n_at_hr_payroll,/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` -(Lokal grün am 2026-09-11: **84/84 Tests**.) \ No newline at end of file +(Lokal grün am 2026-09-12 auf Wegwerf-DB, frischer `-i`-Lauf: +**88/88 Tests** — NET-Rundungscents bucht Odoo als „Adjustment Entry" +auf das Journal-Default.) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/__manifest__.py b/addons/l10n_at_hr_payroll_private/__manifest__.py index 6ff1bed..1c18db4 100644 --- a/addons/l10n_at_hr_payroll_private/__manifest__.py +++ b/addons/l10n_at_hr_payroll_private/__manifest__.py @@ -1,7 +1,7 @@ # Part of the odoo-at-payroll project. License: LGPL-3. { 'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)', - 'version': '19.0.5.0.0', + 'version': '19.0.6.0.0', 'author': 'ixsol / gem360', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', @@ -41,7 +41,7 @@ Grundlagen: ``personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md`` und (§ 41 FLAG), KOMMST (§ 9 KommStG) und DZ (§§ 122/126 WKG, company-flag-gated) mit Freibetragsstaffel 1.095/1.460 € — Logik/Werte im Bundes-Kern (D3-Vorfinanzierung). -- **GP5 Abfertigung Neu (dieser Stand):** BMSVG-Regel ``BVG`` +- **GP5 Abfertigung Neu (umgesetzt):** BMSVG-Regel ``BVG`` (§ 6: 1,53 % DG auf Entgelt und SZ, Kern-Parameter ``at_sv_bv_dg``) auf EIGENER, ungecappter BV-Grundlage (§ 6 Abs 5 iVm § 49 ASVG — kein HBG-Cap, ohne §-67-Abs-3/6-Zeilen), @@ -52,17 +52,34 @@ Grundlagen: ``personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md`` und Monate am Mitarbeiter; Anwartschaftsreport (QWeb) + Endabrechnungs- Wizard (§§ 14-17: Verfügungsausschluss-Matrix, 36-Beitragsmonate, §-16-Fälligkeit, §-17-Optionen). +- **GP6 FI-Buchung UGB (dieser Stand):** Buchungsmatrix nach dem + Österreichischen Einheitskontenrahmen (KFS/BW 6): Hook + ``_configure_payroll_account_at`` (Chart-Load + ````- + Datendatei, idempotent) zuordnet Regelkonten, Journal SLR und das + NET-Konto ``3850 Verbindlichkeiten aus Lohn und Gehalt`` (wird + angelegt, reconcile); **EKR-Debit-Split Arbeiter/Angestellte** + über ``l10n_at_personalgruppe`` am Vertrag + Override-Feld + ``l10n_at_account_debit_arbeiter`` je Regel (Option A minimal: + Grundentgelt 6000, Sozialaufwand 6500, Abgaben 6600/6601/6602); + Netto-Regel auf Odoo-Konvention ``NET`` umbenannt + (Zahlungsregistrierung/SEPA via ``hr_payroll_account_iso20022``). + Auszahlung über ``hr_payroll_account_iso20022``; kein VRV/MVAG + (gem360-Brücke). - **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 (GP6+):** FI-Buchung UGB (GP6), Meldewesen/Belege (GP7), - Jahreswechsel (GP8); §-7-BMSVG-Fiktivfälle (Präsenzdienst/ - Kranken-/Wochengeld) als dokumentierte Option. +- **Folgen (GP7+):** Meldewesen/Belege (GP7), Jahreswechsel (GP8); + All-in-UI-Bericht (GP8); B14 längster-Urlaubs-Teil-Verfeinerung; + DZ-WKO-Verifikation (8.11) vor Wien-Mandanten; §-7-BMSVG- + Fiktivfälle (Präsenzdienst/Kranken-/Wochengeld) als dokumentierte + Option; Referenzbuchhaltungs-Fixierung der Arbeiter-Sub-Konten + (Option A-Rest). Kein VRV-Bezug (Gemeinde-Schiene bleibt ``l10n_at_gemeinde_payroll`` + gem360-Brücke). """, - 'depends': ['l10n_at_hr_payroll', 'hr_payroll_account'], + 'depends': ['l10n_at_hr_payroll', 'hr_payroll_account', + 'l10n_at', 'hr_payroll_account_iso20022'], 'data': [ 'security/ir.model.access.csv', 'data/kv_seed.xml', @@ -76,6 +93,7 @@ gem360-Brücke). 'data/salary_rules_lst_private.xml', 'data/salary_rules_sv_private.xml', 'data/salary_rules_bvg_private.xml', + 'data/hr_payroll_account_private.xml', 'data/bv_kasse_seed.xml', 'views/kv_views.xml', 'views/bv_views.xml', diff --git a/addons/l10n_at_hr_payroll_private/data/hr_payroll_account_private.xml b/addons/l10n_at_hr_payroll_private/data/hr_payroll_account_private.xml new file mode 100644 index 0000000..34fb964 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/hr_payroll_account_private.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + at + + + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/salary_rules_private.xml b/addons/l10n_at_hr_payroll_private/data/salary_rules_private.xml index e666bf6..c39e510 100644 --- a/addons/l10n_at_hr_payroll_private/data/salary_rules_private.xml +++ b/addons/l10n_at_hr_payroll_private/data/salary_rules_private.xml @@ -367,10 +367,14 @@ result = bezahlt * (payslip._at_sz_dienstzeit_faktor() - 1.0) ]]> - + + + + + Netto - NETTO + NET @@ -380,4 +384,15 @@ result = bezahlt * (payslip._at_sz_dienstzeit_faktor() - 1.0) result = categories['ATP_BRUTTO'] - categories['ATP_ABZUG'] ]]> + + + + + + + + + + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/buchhaltung.md b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/buchhaltung.md new file mode 100644 index 0000000..4218619 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/buchhaltung.md @@ -0,0 +1,90 @@ +# Rechenfälle FI-Buchung UGB (GP6) — KFS/BW 6-Buchungsmatrix + +**Quellen:** Österreichischer Einheitskontenrahmen **KFS/BW 6** +(Fachsenat Betriebswirtschaft der Kammer der Wirtschaftstreuhänder +2017; Nutzer-Referenz-Exemplar `.additional_docs/KFSBW6_13072017_ +RF.pdf`, extrahiert nach `/tmp/kfsbw6/` — ad hoc, unversioniert); +Nutzer-Entscheidungen 2026-09-12 (NET-Konto 3850; Arbeiter/Angestellte- +Split bestätigt — 600–619 Löhne, 620–639 Gehälten zugeordnet; +BV-Verbindlichkeit 3600; BV-Aufwand Kandidaten 6440 vor 6400). +Umsetzung: `models/account_chart_private.py` (Matrix + Hook +`_configure_payroll_account_at`), `models/account_posting_private.py` +(EKR-Debit-Split), `data/hr_payroll_account_private.xml` (Trigger für +Bestands-Companies). Tests: `test_account_private.py`. + +## Systematik (GP6, verifiziert) + +- **Odoo-Mechanik:** `hr_payroll_account` buchet je Payslip-Linie auf + `hr.salary.rule.account_debit/account_credit` (Company-Properties); + Posting-Schema: Brutto-Regeln **nur Debit** (Aufwand), DN-Abzüge + **nur Credit** (Verrechnung), NET **nur Credit** auf das + **reconcilable** NET-Konto (`action_register_payment` prüft + `NET.account_credit.reconcile` — deshalb Regelcode-Umbenennung + NETTO → **NET**), DG-Regeln Debit + Credit. Der Move balanciert + automatisch; NET-Konto-Saldo = Netto. +- **Hook-Trigger (beide idempotent):** (1) Chart-Load des + AT-Kontenrahmens → `account.chart.template._post_load_data` → + `_load_payroll_accounts('at', company)`; (2) ``-Datendatei + für Bestands-Companies (`res.company.chart_template = 'at'`; + der ERSTE `` ist die Record-ID-Liste des Aufrufs — Muster + `l10n_be_hr_payroll_account`). Fehlende Konten werden nur gewarnt + (Kontenplan der Referenzbuchhaltung ist verbindlich, GP0-Rest); + Ausnahme NET-Konto **3850** („Verbindlichkeiten aus Lohn und + Gehalt", EKR-Bereich 370–389): wird angelegt und auf reconcile + gesetzt — l10n_at-Standardchart hat es nicht. +- **EKR-Debit-Split Arbeiter/Angestellte** (KFS/BW 6: 600–619 Löhne, + 620–639 Gehälter, 650–655/656–659 Sozialaufwand, 660–665/666–669 + Abgaben): `l10n_at_personalgruppe` am Vertrag steuert je Regel das + Override-Debitkonto `l10n_at_account_debit_arbeiter` + (Gesamtüberschreibung von `_prepare_slip_lines` nach Upstream-Muster, + Odoo-Pin 19.0+e.20260910 — kein nativer Override-Punkt). Credits + gruppenneutral. **Option A minimal:** Overrides nur für + Grundentgelt **6000**, Sozialaufwand **6500**, Abgaben + **6600/6601/6602**; Zuschläge (6225), ÜStd (6310), Prämien (6230), + SZ (6240) und BV (6440, EKR 644 ohne Split) buchen für Arbeiter bis + zur Referenzbuchhaltungs-Fixierung auf die Angestellten-Konten. + +## Buchungsmatrix + +| Regel | Debit Angestellte | Debit Arbeiter (Override) | Credit | +|---|---|---|---| +| KVGEO, URL_UE | 6200 Gehälter | **6000 Löhne** | — | +| NACHT_SV, MEHR_* | 6225 Zulagen | — (Option A) | — | +| UE_* | 6310 Überstunden | — (Option A) | — | +| PRAM | 6230 Prämien | — (Option A) | — | +| B13, B14, B14_RUECK | 6240 Sonderzahlungen | — (Option A) | — | +| SVDN_KV/PV/AV/AK/WF, SVDN_SZ | — | — | 3600 SV | +| LST_LFD, LST_SZ | — | — | 3540 Lohnsteuer | +| **NET** | — | — | **3850 Verbindlichkeiten Lohn/Gehalt** (reconcile) | +| SVDG_* (inkl. SZ, UV_GFG) | 6560 Sozialaufwand Angest. | **6500** | 3600 | +| BVG | 6440 (EKR 644; Fallback 6400) | — | **3600** (Abfuhr via KV-Träger) | +| FLAFDB | 6661 FLAF-DB | 6601 | 3541 Verrechnung DG-Beitrag | +| KOMMST | 6660 KommSt | 6600 | 3550 Verrechnung KommSt | +| DZ | 6662 DZ | 6602 | 3542 Verrechnung DG-Zuschlag | + +Journal: **SLR Lohnverrechnung** (Typ allgemein, Default-Konto +**6200 Gehälter** — Ausgleichsbuchung bei Unbalance, BE-Muster: +Journal-Default = Bezugs-Aufwandskonto; Odoo-Constraint verbietet +Receivable/Payable-Konten als Journal-Default); Structure → +`journal_id`. + +## Rechenfälle (Fixtures test_account_private.py) + +| RF | Konstellation | Erwartung | +|---|---|---| +| RF-BUCH-1 | Angestellte(r), November mit B13; Hook zweimal | Matrix verdrahtet (6200/6240/6560/6440/6660/6661, Credits 3600/3540/3550/3541, NET 3850, Journal SLR); 3850 nur 1×; Move **balanciert**; Zeilensalden je Konto = Payslip-Liniensummen (SV-Saldo 3600 = DN+DG+BV; NET = −3850-Saldo) | +| RF-BUCH-2 | Personalgruppe **Arbeiter**, November mit B13 | Grundentgelt auf **6000** (6200 = 0), SV-DG auf **6500**, KommSt/FLAF auf **6600/6601**; B13 weiter auf 6240 (Option A); DZ ohne WKO-Flag ohne Zeile; balanciert | +| RF-BUCH-3 | Geringfügiger (500 €, §-53a) | 6200 = 500; §-53a-UV 6,50 auf 6560; BVG 7,65 auf 6440; Credit 3600 = 14,15; NET 500 auf 3850; balanciert | +| RF-BUCH-4 | Ohne 6000/6600er/3850 im Plan | Hook warnt (fehlend-Liste), Arbeiter-Override bleibt leer → Fallback 6200 für Grundentgelt; 6500 existiert → SV-Override gesetzt; NET-Konto 3850 wird angelegt (reconcile) | + +## Offene Punkte / bewusst nicht in GP6 + +- **Arbeiter-Sub-Konten** (Zuschläge/ÜStd/Prämien/SZ im 600er-Bereich) + und die 6600er-Kontenbelegung: Option A minimal — endgültige Codes + fixiert die Referenzbuchhaltung (GP0-Rest); Company-Properties sind + übersteuerbar, fehlende Kandidaten führen nur zu einer Warnung. +- Kein Mapping für DAG (keine Payslip-Zeile, Ausweis GP8), keine + Aufrollungs-/Beitragsmonats-Sonderbuchungen (GP8), kein L16/mBGM + (GP7). Kein VRV/MVAG/Ansatz — Gemeinde-Schiene bleibt gem360-Brücke. +- Auszahlung über `hr_payroll_account_iso20022` (SEPA) — Payment- + Registrierung setzt NET-account_credit.reconcile voraus (✓ 3850). \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/__init__.py b/addons/l10n_at_hr_payroll_private/models/__init__.py index 6c08567..16dd4c5 100644 --- a/addons/l10n_at_hr_payroll_private/models/__init__.py +++ b/addons/l10n_at_hr_payroll_private/models/__init__.py @@ -4,4 +4,6 @@ from . import hr_version_private from . import payslip_private from . import bv_kasse from . import abfertigung_private -from . import bv_report \ No newline at end of file +from . import bv_report +from . import account_chart_private +from . import account_posting_private \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/account_chart_private.py b/addons/l10n_at_hr_payroll_private/models/account_chart_private.py new file mode 100644 index 0000000..86c44fc --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/account_chart_private.py @@ -0,0 +1,238 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""GP6 FI-Buchung UGB: Buchungsmatrix nach dem Österreichischen +Einheitskontenrahmen (KFS/BW 6, Fachsenat Betriebswirtschaft der +Kammer der Wirtschaftstreuhänder 2017 — Nutzer-Referenz 2026-09-12) +für die privatwirtschaftliche Structure. + +Matrix (Nutzer-Entscheidungen 2026-09-12): Angestellte buchen auf +Gehälter (620–639), Sozialaufwand Angestellte (656–659) und +gehaltsabhängige Abgaben (666–669); Arbeiter über das Debit-Override- +Feld ``l10n_at_account_debit_arbeiter`` auf Löhne (600–619), +Sozialaufwand Arbeiter (650–655) und lohnabhängige Abgaben (660–665) +— Option A minimal: nur Grundentgelt 6000, Sozialaufwand 6500 und +Abgaben 6600/6601/6602; Zuschläge/ÜStd/SZ/Prämien buchen bis zur +Referenzbuchhaltungs-Fixierung auf die Angestellten-Konten. Credits +sind gruppenneutral (Verrechnungskonten 3540/3541/3542/3550/3600). +NET-Konto **3850 „Verbindlichkeiten aus Lohn und Gehalt"** (EKR-Bereich +370–389 übrige sonstige Verbindlichkeiten) wird angelegt, wenn es +fehlt, und auf reconcile gesetzt — die Zahlungsregistrierung prüft +``NET.account_credit.reconcile``. BV-Verbindlichkeit 3600 (Abfuhr +über den KV-Träger); BV-Aufwand: Kandidatensuche 6440 (EKR 644) +vor 6400 (Odoo-l10n_at-Standard). + +Trigger: (1) automatisch beim Laden des AT-Kontenrahmens +(``account.chart.template._post_load_data`` → +``_load_payroll_accounts``, Kontext ``chart_template_load``) und +(2) über die ````-Datendatei für Bestands-Companies bei +Modul-Install/-Update. Beide Pfade sind idempotent. +""" +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + +# Buchungsmatrix: Regel-XML-ID → Konten-Kandidaten (Präfixsuche je +# Company-Chart; erster Treffer gewinnt; fehlende Konten werden nur +# gewarnt — der Referenzbuchhaltungs-Kontenplan ist verbindlich, +# GP0-Rest). 'debit' = Standard (Angestellte), 'debit_arbeiter' = +# Override für Personalgruppe Arbeiter, 'credit' = Verrechnungskonto. +L10N_AT_BUCHUNGSMATRIX = { + # Entgelt (KFS/BW 6: 600–619 Löhne · 620–639 Gehälter) + 'rule_kvgeo': {'debit': ('6200',), 'debit_arbeiter': ('6000',)}, + 'rule_url_ue': {'debit': ('6200',), 'debit_arbeiter': ('6000',)}, + 'rule_nacht_sv': {'debit': ('6225',)}, + 'rule_mehr_vz': {'debit': ('6225',)}, + 'rule_mehr_vz_nacht': {'debit': ('6225',)}, + 'rule_mehr_tz': {'debit': ('6225',)}, + 'rule_mehr_tz_nacht': {'debit': ('6225',)}, + 'rule_ue_std': {'debit': ('6310',)}, + 'rule_ue_nacht': {'debit': ('6310',)}, + 'rule_ue_nacht_frueh': {'debit': ('6310',)}, + 'rule_ue_sofei': {'debit': ('6310',)}, + 'rule_ue_sofei_nacht': {'debit': ('6310',)}, + 'rule_pram': {'debit': ('6230',)}, + 'rule_b13': {'debit': ('6240',)}, + 'rule_b14': {'debit': ('6240',)}, + 'rule_b14_rueck': {'debit': ('6240',)}, + # Abzüge DN (§ 62-Kette) und Lohnsteuer — Credit-Verrechnungen + 'rule_svdn_kv': {'credit': ('3600',)}, + 'rule_svdn_pv': {'credit': ('3600',)}, + 'rule_svdn_av': {'credit': ('3600',)}, + 'rule_svdn_ak': {'credit': ('3600',)}, + 'rule_svdn_wf': {'credit': ('3600',)}, + 'rule_svdn_sz': {'credit': ('3600',)}, + 'rule_lst_lfd': {'credit': ('3540',)}, + 'rule_lst_sz': {'credit': ('3540',)}, + # Nettoauszahlung (reconcilable, s. o.) + 'rule_netto': {'credit': ('3850',)}, + # DG-Beiträge (650–655 SV Arbeiter · 656–659 SV Angestellte) + 'rule_svdg_kv': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_pv': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_av': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_uv': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_wf': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_iesg': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_sz_kv': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_sz_pv': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_sz_av': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_sz_uv': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_sz_iesg': {'debit': ('6560',), + 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + 'rule_svdg_uv_gfg': {'debit': ('6560',), 'debit_arbeiter': ('6500',), + 'credit': ('3600',)}, + # Abfertigung Neu: Aufwand 644 (EKR) mit Fallback Odoo-Standard + # 6400; Abfuhr über den KV-Träger → Verrechnung 3600 (5.3). + 'rule_bvg': {'debit': ('6440', '6400'), 'credit': ('3600',)}, + # Lohnabhängige Abgaben: 666er gehaltsabhängig (Angestellte), + # 660er lohnabhängig (Arbeiter); Verrechnungen 3541/3550/3542. + 'rule_flafdb': {'debit': ('6661',), 'debit_arbeiter': ('6601',), + 'credit': ('3541',)}, + 'rule_kommst': {'debit': ('6660',), 'debit_arbeiter': ('6600',), + 'credit': ('3550',)}, + 'rule_dz': {'debit': ('6662',), 'debit_arbeiter': ('6602',), + 'credit': ('3542',)}, +} + + +class HrSalaryRule(models.Model): + _inherit = 'hr.salary.rule' + + l10n_at_account_debit_arbeiter = fields.Many2one( + 'account.account', string='AT Debitkonto Arbeiter (EKR)', + company_dependent=True, ondelete='restrict', + help='Debit-Override der Lohnverrechnungsbuchung für Arbeiter ' + '(KFS/BW 6: Löhne 600–619, Sozialaufwand Arbeiter ' + '650–655, lohnabhängige Abgaben 660–665). Leer = ' + 'Buchung auf das Standard-Debitkonto (Angestellte). ' + 'Nur Aufwandskonten splitten; Verrechnungskonten sind ' + 'gruppenneutral.') + + +class AccountChartTemplate(models.AbstractModel): + _inherit = 'account.chart.template' + + def _l10n_at_buchungskonto(self, company, codes): + """Konto je Kandidaten-Code: exakter Code zuerst, sonst + Praefix-Fallback nur fuer leicht verlaengerte Konten (max. eine + Stelle, z. B. 6200 → 62000) — blockiert Odoo-Scaffold-Konten + chartloser Firmen (6000 darf nicht auf 600000 „Expenses" + matchen). Leer, wenn nichts Passendes existiert.""" + account_model = self.env['account.account'].with_company(company) + basis = account_model._check_company_domain(company) + for code in codes: + account = account_model.search( + basis + [('code', '=', code)], limit=1) + if account: + return account + kandidaten = account_model.search( + basis + [('code', '=like', '%s%%' % code)]) + account = kandidaten.filtered( + lambda a: len(a.code) <= len(code) + 1)[:1] + if account: + return account + return self.env['account.account'] + + def _l10n_at_netto_konto(self, company): + """NET-Konto 3850 'Verbindlichkeiten aus Lohn und Gehalt' + (EKR 370–389): anlegen, wenn es fehlt; reconcile erzwingen — + die Zahlungsregistrierung prüft NET.account_credit.reconcile + konten (hr_payroll_account) und l10n_at führt alle Verrechnungs- + konten reconcile.""" + konto = self._l10n_at_buchungskonto(company, ('3850',)) + if not konto: + konto = self.env['account.account'].with_company( + company).create({ + 'name': 'Verbindlichkeiten aus Lohn und Gehalt', + 'code': '3850', + 'account_type': 'liability_payable', + 'reconcile': True, + }) + elif not konto.reconcile: + konto.reconcile = True + return konto + + def _l10n_at_lohnjournal(self, company, aufwand_konto): + """Lohnjournal SLR (Code des hr_payroll_account-Templates) + suchen oder anlegen. Default-Konto = Gehälter-Aufwandskonto + (6200, BE-Muster: Journal-Default des Lohnjournals ist das + Bezugs-Aufwandskonto; die Unbalance-Ausgleichsbuchung + _prepare_adjust_line läuft darüber) — NICHT das NET-Konto: + Journal-Defaultkonten dürfen nach Odoo-Constraint nicht + Receivable/Payable sein (Constraint + _check_account_is_bank_journal_bank_account).""" + journal_model = self.env['account.journal'].with_company(company) + journal = journal_model.search([ + *journal_model._check_company_domain(company), + ('code', '=', 'SLR'), + ], limit=1) + if not journal: + journal = journal_model.create({ + 'name': 'Lohnverrechnung', + 'code': 'SLR', + 'type': 'general', + }) + if not journal.default_account_id and aufwand_konto: + journal.default_account_id = aufwand_konto.id + return journal + + def _configure_payroll_account_at(self, companies): + """Buchungsmatrix je Company anwenden (idempotent): Konten je + Kandidaten-Code suchen (fehlende nur warnen — der Kontenplan + der Referenzbuchhaltung ist verbindlich, GP0-Rest), NET-Konto + 3850 sicherstellen, Journal SLR zuordnen.""" + netto_konto = False + journal = False + fehlend = [] + for company in companies: + netto_konto = self._l10n_at_netto_konto(company) + gehaelter_konto = self._l10n_at_buchungskonto(company, ('6200',)) + if not gehaelter_konto: + fehlend.append('rule_kvgeo → (\'6200\',) [Journal-Standard]') + journal = self._l10n_at_lohnjournal(company, gehaelter_konto) + for xmlid, mapping in L10N_AT_BUCHUNGSMATRIX.items(): + rule = self.env.ref( + 'l10n_at_hr_payroll_private.%s' % xmlid, raise_if_not_found=False) + if not rule: + continue + vals = {} + for feld, schluessel in ( + ('account_debit', 'debit'), + ('l10n_at_account_debit_arbeiter', 'debit_arbeiter'), + ('account_credit', 'credit')): + codes = mapping.get(schluessel) + if not codes: + continue + if codes == ('3850',): + konto = netto_konto + else: + konto = self._l10n_at_buchungskonto(company, codes) + if not konto: + fehlend.append('%s → %s' % (xmlid, codes)) + continue + vals[feld] = konto.id + if vals: + rule.with_company(company).write(vals) + struktur = self.env.ref( + 'l10n_at_hr_payroll_private.structure_at_privat', + raise_if_not_found=False) + if struktur: + struktur.with_company(company).journal_id = journal + if fehlend: + _logger.warning( + 'AT-Payroll-Buchungsmatrix: fehlende Konten nicht ' + 'zugeordnet (Referenzbuchhaltung pflegen): %s', + ', '.join(fehlend)) + return {'netto_konto': netto_konto, 'journal': journal, + 'fehlend': fehlend} \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/account_posting_private.py b/addons/l10n_at_hr_payroll_private/models/account_posting_private.py new file mode 100644 index 0000000..0764911 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/account_posting_private.py @@ -0,0 +1,89 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""GP6: EKR-Debit-Split Arbeiter/Angestellte im Buchungslauf. + +``hr_payroll_account`` liest die Buchungskonten statisch je Regel +(``hr.salary.rule.account_debit`` — Company-Property). Der Arbeiter/ +Angestellte-Split des Einheitskontenrahmen braucht dieselbe Regel je +Personalgruppe auf zwei Konten — Odoo bietet dafür keinen Override- +Punkt (Accounts werden direkt in der Posting-Schleife gelesen). +Deshalb Gesamtüberschreibung von ``_prepare_slip_lines`` nach dem +Upstream-Muster (``hr_payroll_account/models/hr_payslip.py``, Odoo- +Pin 19.0+e.20260910) mit genau einer Substitution: Debit-Konto = +``l10n_at_account_debit_arbeiter``, wenn die Personalgruppe des +Vertrags 'arbeiter' ist und ein Override gesetzt ist. Credits sind +gruppenneutral (Verrechnungskonten). Das Verhalten ist durch +``test_account_private.py`` gepinnt; bei Odoo-Upgrades gegen den Pin +neu abgleichen (Muster: hr_payroll_account Upstream). +""" +from odoo import models +from odoo.tools import float_is_zero + + +class HrPayslip(models.Model): + _inherit = 'hr.payslip' + + def _prepare_slip_lines(self, date, line_ids): + self.ensure_one() + precision = self.env['decimal.precision'].precision_get('Payroll') + new_lines = [] + for line in self.line_ids.filtered(lambda line: line.category_id): + amount = line.total + if line.code == 'NET': # Check if the line is the 'Net Salary'. + for tmp_line in self.line_ids.filtered(lambda line: line.category_id): + if tmp_line.salary_rule_id.not_computed_in_net: # Check if the rule must be computed in the 'Net Salary' or not. + if amount > 0: + amount -= abs(tmp_line.total) + elif amount < 0: + amount += abs(tmp_line.total) + if float_is_zero(amount, precision_digits=precision): + continue + debit_account = line.salary_rule_id.account_debit + credit_account = line.salary_rule_id.account_credit + # GP6 EKR-Split (KFS/BW 6): Personalgruppe 'arbeiter' am + # Vertrag bucht auf das Arbeiter-Debitkonto, wenn ein + # Override gesetzt ist (Option A minimal); sonst Standard- + # debit (Angestellte). Referenzbuchhaltung pflegt die + # Company-Properties je Kontenplan. + if (self.version_id.l10n_at_personalgruppe == 'arbeiter' + and line.salary_rule_id.l10n_at_account_debit_arbeiter): + debit_account = \ + line.salary_rule_id.l10n_at_account_debit_arbeiter + merge_amounts = self.company_id.batch_payroll_move_lines \ + or not line.salary_rule_id.employee_move_line + + if debit_account.id: # If the rule has a debit account. + if self.company_id.account_storno and self.credit_note: + debit = amount if amount < 0.0 else 0.0 + credit = -amount if amount > 0.0 else 0.0 + else: + debit = amount if amount > 0.0 else 0.0 + credit = -amount if amount < 0.0 else 0.0 + + debit_line = merge_amounts and next(self._get_existing_lines( + line_ids + new_lines, line, debit_account.id, debit, credit), False) + + if not debit_line: + debit_lines = self._prepare_line_values(line, debit_account, date, debit, credit) + new_lines.extend(debit_lines) + else: + debit_line['debit'] += debit + debit_line['credit'] += credit + + if credit_account.id: # If the rule has a credit account. + if self.company_id.account_storno and self.credit_note: + debit = -amount if amount > 0.0 else 0.0 + credit = amount if amount < 0.0 else 0.0 + else: + debit = -amount if amount < 0.0 else 0.0 + credit = amount if amount > 0.0 else 0.0 + + credit_line = merge_amounts and next(self._get_existing_lines( + line_ids + new_lines, line, credit_account.id, debit, credit), False) + + if not credit_line: + credit_lines = self._prepare_line_values(line, credit_account, date, debit, credit) + new_lines.extend(credit_lines) + else: + credit_line['debit'] += debit + credit_line['credit'] += credit + return new_lines \ 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 index db52cbe..22849e0 100644 --- a/addons/l10n_at_hr_payroll_private/models/hr_version_private.py +++ b/addons/l10n_at_hr_payroll_private/models/hr_version_private.py @@ -33,6 +33,17 @@ class HrVersion(models.Model): "z. B. § 18 Anrechnung der Rahmen-KVs).") currency_id = fields.Many2one( 'res.currency', related='company_id.currency_id') + l10n_at_personalgruppe = fields.Selection( + [('angestellte', 'Angestellte (Gehälter)'), + ('arbeiter', 'Arbeiter (Löhne)')], + string='AT Personalgruppe (EKR)', + default='angestellte', + groups="hr_payroll.group_hr_payroll_user", tracking=True, + help='Personalgruppe nach dem Einheitskontenrahmen (KFS/BW 6): ' + 'steuert die Debit-Konten der Lohnverrechnungsbuchung ' + '(600–619 Löhne · 620–639 Gehälter · 650/656 Sozialaufwand ' + '· 660/666 Abgaben). Rechenlogik (KV-Regime) ist identisch; ' + 'nur die FI-Buchung splittet (GP6).') def _get_kv_gehalt(self, datum=None): """Katalog-Position (Monatsgehalt) der Version zum Stichtag.""" @@ -55,4 +66,7 @@ class HrEmployee(models.Model): 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 + currency_field='currency_id') + l10n_at_personalgruppe = fields.Selection( + related="version_id.l10n_at_personalgruppe", inherited=True, + readonly=False, groups="hr_payroll.group_hr_payroll_user") \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/payslip_private.py b/addons/l10n_at_hr_payroll_private/models/payslip_private.py index 619b561..1a79f7d 100644 --- a/addons/l10n_at_hr_payroll_private/models/payslip_private.py +++ b/addons/l10n_at_hr_payroll_private/models/payslip_private.py @@ -14,6 +14,32 @@ class HrWorkEntryType(models.Model): '(zuschlagsunabhängig, kumulativ).') +class HrPayrollStructure(models.Model): + _inherit = 'hr.payroll.structure' + + def _l10n_at_entferne_default_net_kopie(self): + """Odoo kopiert beim Anlegen einer Structure die Regeln des + ``hr_payroll.default_structure`` (BASIC/GROSS/NET/ASSIG_ + SALARY/…) in ``rule_ids`` (Feld-Default ``_get_default_ + rule_ids``). Die kopierte NET-Regel kollidiert ab GP6 mit + unserer Netto-Regel (Code ``NET`` nach Odoo-Konvention — + Zahlungsregistrierung, net_wage und Negativ-Netto-Erkennung + filtern ``code == 'NET'``): zwei Regeln mit Code NET in einer + Structure überschreiben sich im ``result_rules``-Dict und die + Netto-Zeile fällt auf 0. Die Kopie wird daher archiviert — + One2many-Liesungen (rule_ids, Compute-Schleife, NET-Filter) + blenden Archivierte aus, historische Lohnkontolinien behalten + aber ihre Regel-Referenz (struct_id ist NOT NULL, ein Abhängen + ist nicht möglich). Idempotent (Aufruf via in + salary_rules_private.xml bei Install und Update).""" + self.ensure_one() + unsere = self.env.ref( + 'l10n_at_hr_payroll_private.rule_netto', False) + kopien = self.rule_ids.filtered( + lambda r: r.code == 'NET' and (not unsere or r != unsere)) + kopien.write({'active': False}) + + class HrVersionPrivate(models.Model): _inherit = 'hr.version' diff --git a/addons/l10n_at_hr_payroll_private/tests/__init__.py b/addons/l10n_at_hr_payroll_private/tests/__init__.py index f8b966e..b20f7a2 100644 --- a/addons/l10n_at_hr_payroll_private/tests/__init__.py +++ b/addons/l10n_at_hr_payroll_private/tests/__init__.py @@ -3,4 +3,5 @@ from . import test_kv_katalog from . import test_entgelt_private from . import test_lst_private from . import test_sv_private -from . import test_bv_private \ No newline at end of file +from . import test_bv_private +from . import test_account_private \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/tests/test_account_private.py b/addons/l10n_at_hr_payroll_private/tests/test_account_private.py new file mode 100644 index 0000000..87bad16 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_account_private.py @@ -0,0 +1,279 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""GP6 FI-Buchung UGB: Buchungsmatrix (KFS/BW 6 — Österreichischer +Einheitskontenrahmen) und EKR-Debit-Split Arbeiter/Angestellte. +Hook-Anwendung (Kontenzuordnung/Journal/NET-Konto), balancierte +Moves mit Kontenzeilen je Matrix, Arbeiter-Overrides (Option A +minimal) und Fallback-Verhalten bei fehlenden Konten. + +Rechenfälle: docs/rechenfaelle/buchhaltung.md. Der Kontenplan wird +im Test angelegt (Kandidaten-Präfixsuche des Hooks wie im echten +Chart) — ein Chart-Install ist dafür nicht nötig.""" +from datetime import date, timedelta + +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class AccountPrivateTest(TransactionCase): + + def setUp(self): + super().setUp() + self.structure = self.env.ref( + 'l10n_at_hr_payroll_private.structure_at_privat') + self.kv2203 = self.env.ref( + 'l10n_at_hr_payroll_private.kv_si_2203') + self.gruppe_ii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_ii') + self.calendar = self.env['resource.calendar'].create({ + 'name': 'AT Test 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) + ], + }) + self.env.company.resource_calendar_id = self.calendar + + # ------------------------------------------------------------------ + # Kontenplan-Mock (Kandidaten wie in models/account_chart_private) + # ------------------------------------------------------------------ + def _konto(self, code, typ='expense', reconcile=False): + return self.env['account.account'].create({ + 'name': 'Test %s' % code, + 'code': code, + 'account_type': typ, + 'reconcile': reconcile, + }) + + def _kontenplan(self, ohne=()): + """EKR-Kontenplan-Mock; ``ohne`` lässt Kandidaten bewusst weg + (RF-BUCH-4: Fallback statt Erfindung).""" + for code in ('6000', '6200', '6225', '6230', '6240', '6310', + '6440', '6500', '6560', '6600', '6601', '6602', + '6660', '6661', '6662'): + if code not in ohne: + self._konto(code, 'expense') + for code in ('3540', '3541', '3542', '3550', '3600'): + self._konto(code, 'liability_current', reconcile=True) + if '3850' not in ohne: + self._konto('3850', 'liability_payable', reconcile=True) + + def _hook(self): + return self.env['account.chart.template'] \ + ._configure_payroll_account_at(self.env.company) + + def _employee(self, version_vals=None): + employee = self.env['hr.employee'].create({ + 'name': 'Test FI privat', + }) + version = self.env['hr.version'].search( + [('employee_id', '=', employee.id)], limit=1) + if not version: + version = self.env['hr.version'].create({ + 'employee_id': employee.id, + 'date_start': date(2020, 1, 1)}) + version.write({ + 'contract_date_start': date(2020, 1, 1), + 'kv_id': self.kv2203.id, + 'kv_gruppe_id': self.gruppe_ii.id, + 'erfahrungsstufe': 1, + 'resource_calendar_id': self.calendar.id, + }) + if version_vals: + version.write(version_vals) + return employee + + def _slip(self, employee, monat, jahr=2026, confirm=False): + erster = date(jahr, monat, 1) + naechster = (erster + timedelta(days=32)).replace(day=1) + slip = self.env['hr.payslip'].create({ + 'name': 'Test-FI-Privat %d/%d' % (monat, jahr), + 'employee_id': employee.id, + 'struct_id': self.structure.id, + 'date_from': erster, + 'date_to': naechster - timedelta(days=1), + }) + slip.compute_sheet() + if confirm: + slip.action_payslip_done() + return slip + + def _total(self, slip, code): + return sum(slip.line_ids.filtered( + lambda l: l.code == code).mapped('total')) + + def _saldo(self, lines, code): + """Debit − Credit je Kontoklasse des Moves.""" + teil = lines.filtered(lambda l: l.account_id.code == code) + return sum(teil.mapped('debit')) - sum(teil.mapped('credit')) + + # ------------------------------------------------------------------ + # RF-BUCH-1: Matrix, NET-Konto, balancierter Move (Angestellte) + # ------------------------------------------------------------------ + def test_01_matrix_und_move_angestellte(self): + """Hook verdrahtet die Regelkonten (6200/6240/6560/6440/6660/ + 6661, Verrechnungen 3600/3540/3550/3541, NET 3850 reconcile, + Journal SLR) und ist idempotent; der November-Lauf mit B13 + buchet balanciert — jede Zeile auf ihrem Matrix-Konto.""" + self._kontenplan() + ergebnis = self._hook() + self.assertEqual(ergebnis['netto_konto'].code, '3850') + self.assertTrue(ergebnis['netto_konto'].reconcile) + self.assertTrue(ergebnis['journal']) + self.assertEqual(ergebnis['fehlend'], []) + # Stichproben der Matrix (Angestellte = Standard-Debit) + ref = self.env.ref + module = 'l10n_at_hr_payroll_private' + self.assertEqual( + ref('%s.rule_kvgeo' % module).account_debit.code, '6200') + self.assertEqual( + ref('%s.rule_b13' % module).account_debit.code, '6240') + self.assertEqual( + ref('%s.rule_svdg_kv' % module).account_debit.code, '6560') + self.assertEqual( + ref('%s.rule_svdg_kv' % module).account_credit.code, '3600') + self.assertEqual( # EKR 6440 vor Odoo-Fallback 6400 + ref('%s.rule_bvg' % module).account_debit.code, '6440') + self.assertEqual( + ref('%s.rule_netto' % module).account_credit.code, '3850') + # Idempotenz: zweiter Lauf erzeugt kein zweites NET-Konto. + self._hook() + self.assertEqual(self.env['account.account'].search_count([ + ('code', '=like', '3850%')]), 1) + # Move: balanciert, Zeilen je Matrix-Konto + employee = self._employee() + slip = self._slip(employee, 11, confirm=True) + self.assertTrue(slip.move_id) + lines = slip.move_id.line_ids + self.assertAlmostEqual(sum(lines.mapped('debit')), + sum(lines.mapped('credit')), places=2) + # Aufwands- und Lohnkonten mit Rundungstoleranz: Odoo bucht + # Restcents des Moves als „Adjustment Entry" auf das + # Journal-Default (6200, BE-Muster) — NET-Linie und Summen der + # gerundeten Abzugszeilen können um Cents differieren. + self.assertAlmostEqual(self._saldo(lines, '6200'), + self._total(slip, 'KVGEO'), delta=0.02) + self.assertAlmostEqual(self._saldo(lines, '6240'), + self._total(slip, 'B13'), places=2) + dg = sum(self._total(slip, code) for code in ( + 'SVDG_KV', 'SVDG_PV', 'SVDG_AV', 'SVDG_UV', 'SVDG_WF', + 'SVDG_IESG', 'SVDG_SZ_KV', 'SVDG_SZ_PV', 'SVDG_SZ_AV', + 'SVDG_SZ_UV', 'SVDG_SZ_IESG')) + self.assertAlmostEqual(self._saldo(lines, '6560'), dg, places=2) + self.assertAlmostEqual(self._saldo(lines, '6440'), + self._total(slip, 'BVG'), places=2) + self.assertAlmostEqual(self._saldo(lines, '6660'), + self._total(slip, 'KOMMST'), places=2) + self.assertAlmostEqual(self._saldo(lines, '6661'), + self._total(slip, 'FLAFDB'), places=2) + # Verrechnungen: SV gesamt (DN + DG + BV) und Lohnsteuer + sv = dg + self._total(slip, 'BVG') + sum( + self._total(slip, code) for code in ( + 'SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK', 'SVDN_WF', + 'SVDN_SZ')) + self.assertAlmostEqual(self._saldo(lines, '3600'), -sv, places=1) + lst = sum(self._total(slip, code) for code in + ('LST_LFD', 'LST_SZ')) + self.assertAlmostEqual(self._saldo(lines, '3540'), -lst, places=1) + self.assertAlmostEqual(self._saldo(lines, '3550'), + -self._total(slip, 'KOMMST'), places=2) + self.assertAlmostEqual(self._saldo(lines, '3541'), + -self._total(slip, 'FLAFDB'), places=2) + self.assertAlmostEqual(self._saldo(lines, '3850'), + -self._total(slip, 'NET'), places=1) + + # ------------------------------------------------------------------ + # RF-BUCH-2: EKR-Debit-Split Personalgruppe Arbeiter (Option A) + # ------------------------------------------------------------------ + def test_02_arbeiter_debit_split(self): + """Arbeiter buchen Grundentgelt auf 6000 (Löhne), SV-DG auf + 6500, KommSt/FLAF auf 6600/6601; die B13 bleibt auf 6240 + (Option A: kein Arbeiter-Override für SZ); Credits + gruppenneutral; DZ ohne WKO-Flag ohne Zeile.""" + self._kontenplan() + self._hook() + employee = self._employee( + {'l10n_at_personalgruppe': 'arbeiter'}) + slip = self._slip(employee, 11, confirm=True) + lines = slip.move_id.line_ids + self.assertAlmostEqual(sum(lines.mapped('debit')), + sum(lines.mapped('credit')), places=2) + self.assertAlmostEqual(self._saldo(lines, '6000'), + self._total(slip, 'KVGEO'), delta=0.02) + self.assertAlmostEqual(self._saldo(lines, '6200'), 0.0, delta=0.02) + self.assertAlmostEqual(self._saldo(lines, '6240'), + self._total(slip, 'B13'), places=2) + dg = sum(self._total(slip, code) for code in ( + 'SVDG_KV', 'SVDG_PV', 'SVDG_AV', 'SVDG_UV', 'SVDG_WF', + 'SVDG_IESG', 'SVDG_SZ_KV', 'SVDG_SZ_PV', 'SVDG_SZ_AV', + 'SVDG_SZ_UV', 'SVDG_SZ_IESG')) + self.assertAlmostEqual(self._saldo(lines, '6500'), dg, places=2) + self.assertAlmostEqual(self._saldo(lines, '6560'), 0.0, places=2) + self.assertAlmostEqual(self._saldo(lines, '6600'), + self._total(slip, 'KOMMST'), places=2) + self.assertAlmostEqual(self._saldo(lines, '6601'), + self._total(slip, 'FLAFDB'), places=2) + self.assertAlmostEqual(self._saldo(lines, '6602'), 0.0, places=2) + # 6440 BV-Aufwand — kein Arbeiter-Override (EKR 644 ohne Split) + self.assertAlmostEqual(self._saldo(lines, '6440'), + self._total(slip, 'BVG'), places=2) + + # ------------------------------------------------------------------ + # RF-BUCH-3: Geringfügiger (§-53a-Fall) buchet sauber + # ------------------------------------------------------------------ + def test_03_geringfuegig_move(self): + """Geringfügiger: 6200 500,00; §-53a-UV 6,50 auf 6560, BVG + 7,65 auf 6440 (beide Credit 3600); NET 500,00 auf 3850 — + Move balanciert.""" + self._kontenplan() + self._hook() + employee = self._employee({'ueberzahlung': 500.0}) + slip = self._slip(employee, 1, confirm=True) + lines = slip.move_id.line_ids + self.assertAlmostEqual(sum(lines.mapped('debit')), + sum(lines.mapped('credit')), places=2) + self.assertAlmostEqual(self._saldo(lines, '6200'), 500.0, places=2) + self.assertAlmostEqual(self._saldo(lines, '6560'), 6.50, places=2) + self.assertAlmostEqual(self._saldo(lines, '6440'), 7.65, places=2) + self.assertAlmostEqual(self._saldo(lines, '3600'), -(6.50 + 7.65), + places=2) + self.assertAlmostEqual(self._saldo(lines, '3850'), -500.0, places=2) + + # ------------------------------------------------------------------ + # RF-BUCH-4: Fehlende Konten → Warnung + Fallback, NET-Konto-Anlage + # ------------------------------------------------------------------ + def test_04_hook_fehlende_konten(self): + """Ohne 6000/6600er bleibt der Arbeiter-Override leer + (Buchung fällt auf die Angestellten-Konten zurück), fehlende + Kandidaten werden nur gemeldet; das NET-Konto 3850 legt der + Hook selbst an (reconcile) — Odoo-l10n_at-Chart hat es nicht.""" + self._kontenplan(ohne=('6000', '6600', '6601', '6602', '3850')) + ergebnis = self._hook() + self.assertIn('rule_kvgeo', ' '.join(ergebnis['fehlend'])) + self.assertIn('rule_kommst', ' '.join(ergebnis['fehlend'])) + self.assertEqual(ergebnis['netto_konto'].code, '3850') + self.assertTrue(ergebnis['netto_konto'].reconcile) + ref = self.env.ref + module = 'l10n_at_hr_payroll_private' + self.assertFalse( + ref('%s.rule_kvgeo' % module).l10n_at_account_debit_arbeiter) + # 6500 existiert → SV-Override wird trotzdem gesetzt (Option A) + self.assertEqual( + ref('%s.rule_svdg_kv' % module) + .l10n_at_account_debit_arbeiter.code, '6500') + employee = self._employee( + {'l10n_at_personalgruppe': 'arbeiter'}) + slip = self._slip(employee, 11, confirm=True) + lines = slip.move_id.line_ids + # Fallback: Grundentgelt des Arbeiters buchet auf 6200 + # (± Restcent-Ausgleich auf dem Journal-Default 6200) + self.assertAlmostEqual(self._saldo(lines, '6200'), + self._total(slip, 'KVGEO'), delta=0.02) + # SV-DG des Arbeiters buchet auf 6500 + dg = sum(self._total(slip, code) for code in ( + 'SVDG_KV', 'SVDG_PV', 'SVDG_AV', 'SVDG_UV', 'SVDG_WF', + 'SVDG_IESG', 'SVDG_SZ_KV', 'SVDG_SZ_PV', 'SVDG_SZ_AV', + 'SVDG_SZ_UV', 'SVDG_SZ_IESG')) + self.assertAlmostEqual(self._saldo(lines, '6500'), dg, places=2) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/tests/test_bv_private.py b/addons/l10n_at_hr_payroll_private/tests/test_bv_private.py index 6983b17..b89b177 100644 --- a/addons/l10n_at_hr_payroll_private/tests/test_bv_private.py +++ b/addons/l10n_at_hr_payroll_private/tests/test_bv_private.py @@ -134,11 +134,11 @@ class BvPrivateTest(TransactionCase): self.assertAlmostEqual(basis, 4118.82, places=2) self.assertAlmostEqual(self._total(slip, 'BVG'), 4118.82 * 0.0153, places=2) - # Kein Nettoeffekt: ATP_DG bleibt außerhalb der NETTO-Rechnung. + # Kein Nettoeffekt: ATP_DG bleibt außerhalb der NET-Rechnung. abzug = sum(self._total(slip, code) for code in ('SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK', 'SVDN_WF', 'SVDN_SZ', 'LST_LFD', 'LST_SZ')) - self.assertAlmostEqual(self._total(slip, 'NETTO'), + self.assertAlmostEqual(self._total(slip, 'NET'), basis - abzug, places=1) def test_02_kein_hbg_cap(self): diff --git a/addons/l10n_at_hr_payroll_private/tests/test_entgelt_private.py b/addons/l10n_at_hr_payroll_private/tests/test_entgelt_private.py index c566e56..f4df370 100644 --- a/addons/l10n_at_hr_payroll_private/tests/test_entgelt_private.py +++ b/addons/l10n_at_hr_payroll_private/tests/test_entgelt_private.py @@ -385,7 +385,7 @@ class EntgeltPrivateTest(TransactionCase): for code in ('SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK', 'SVDN_WF', 'SVDN_SZ', 'LST_LFD', 'LST_SZ')) self.assertAlmostEqual( - self._total(slip, 'NETTO'), brutto - abzug, places=1) + self._total(slip, 'NET'), brutto - abzug, places=1) def test_21_allin_deckungsrechnung(self): """§ 2g AVRAG: Jahresdeckung = 12 × Pauschale vs. geschuldete diff --git a/addons/l10n_at_hr_payroll_private/tests/test_lst_private.py b/addons/l10n_at_hr_payroll_private/tests/test_lst_private.py index 51df5da..79bb2c0 100644 --- a/addons/l10n_at_hr_payroll_private/tests/test_lst_private.py +++ b/addons/l10n_at_hr_payroll_private/tests/test_lst_private.py @@ -116,7 +116,7 @@ class LstPrivateTest(TransactionCase): ('SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK', 'SVDN_WF', 'SVDN_SZ', 'LST_LFD', 'LST_SZ')) self.assertAlmostEqual( - self._total(slip, 'NETTO'), brutto - abzug, places=1) + self._total(slip, 'NET'), brutto - abzug, places=1) def test_02_rollung_zweiter_monat(self): """Kumulative §-66-Rollung: Februar zahlt die Differenz.""" diff --git a/addons/l10n_at_hr_payroll_private/tests/test_sv_private.py b/addons/l10n_at_hr_payroll_private/tests/test_sv_private.py index c6bdd1f..d7ee443 100644 --- a/addons/l10n_at_hr_payroll_private/tests/test_sv_private.py +++ b/addons/l10n_at_hr_payroll_private/tests/test_sv_private.py @@ -143,7 +143,7 @@ class SvPrivateTest(TransactionCase): ('SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK', 'SVDN_WF', 'SVDN_SZ', 'LST_LFD', 'LST_SZ')) self.assertAlmostEqual( - self._total(slip, 'NETTO'), brutto - abzug, places=1) + self._total(slip, 'NET'), brutto - abzug, places=1) def test_03_wien_dg(self): """Wien: SVDG_WF 0,75 % (Satzvektor, Wiener WF-Beitragstarif @@ -242,4 +242,4 @@ class SvPrivateTest(TransactionCase): # LSt: Bemessung 500 − 0 → Tarif unterhalb der Belastungszone self.assertAlmostEqual(self._total(slip, 'LST_LFD'), 0.0, places=1) self.assertAlmostEqual( - self._total(slip, 'NETTO'), 500.0, places=2) \ No newline at end of file + self._total(slip, 'NET'), 500.0, places=2) \ No newline at end of file