From 14940ac31a65b0ddbe087ce0ea36ee2e88808446 Mon Sep 17 00:00:00 2001 From: fegger Date: Sat, 12 Sep 2026 17:31:12 +0200 Subject: [PATCH] [IMP] l10n_at_hr_payroll_private: GP7 mBGM/JASV + Versichertenmeldung + fallweise Anmeldung auf dem ELDA-Framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mBGM/JASV (models/mbgm.py — DM-ORG 42.7.0 E.32, BEST MB, Version 02): Paket PS (BKNR/DGNA/BZRM/GSUM/ANZM) → mBGM G1 (REFW/VSNR/FANA/VONA/VSUM/VERG=1) → Tarifblock T1 (BSGR/VVON) → Verrechnungsbasis BS (VBTY/VBBT) → Verrechnungsposition V1 (VPTY/VPVZ/VPTA/RSVZ/RSUM). KEIN DN/DG-Zweig-Split: T01 = GESAMT-Prozentsatz des Tarifsystems je Beschäftigtengruppe (Parameter at_mbgm_tarif, normativ: laufend 39,05 % = KV 7,65 + PV 22,80 + AV 5,90 + UV 1,10 + WF 1,00 + IESG 0,10; SZ 37,55 % = DN KV 3,87 + PV 10,25 + AV 2,95 + DG 20,48). Abschläge als EIGENE Verrechnungspositionen je Basis: A03/A02/A01 = Minderung AV auf 0/1/2 % (§ 2a AMPFG, nur DN-AV, je Verrechnungsbasis AB UND SZ), A09 = UV-Entfall 60, A10 = AV+IE-Entfall Pensionsanspruch (Konkurrenz a4: sperrt Staffelabschlag), A15 = PV-Halbierung Bonusphase — konsistent mit der GP4-§-2a-DN-seitig-Konvention. BV (BMSVG): eigene Verrechnungsbasis BV (ungedeckelt, GP5) + Position V01 1,53 %. RSUM = Basis x Satz (mBGM-Tarifierung), Konsistenz-Check gegen GP4-Payslip-Beitragssummen ±0,10 € als Prüfwarnung. Storno: R1-Sätze mit REFU/VSUM wie Original. Fehlanzeige: Paket ohne mBGM-Sätze (ANZM 0). Geringfügige/fallweise ohne Parametrisierung: listenweiser Abbruch (keine Tarif-Erfindung; BSGR B044/B045/B010/B030 aus dem SV-Tarifsystem pflegen). Versichertenmeldung (models/versichertenmeldung.py — DM-ORG E.29, BEST VR, Version 03, Satzlänge 772): M3 vor Arbeitsantritt / M4 (7 Kalendertage ab PV-Ende) mit deklarativer Event-Erkennung über die Meldungs-Registry (l10n.at.payroll.vr.event) — idempotente Läufe, Dimona-auto-Idee ohne write-diff-Hacks. Felder: BKNR/VSNR/GEBD/FANA/VONA (Namenssplit am letzten Leerzeichen), ADAT/EBSV, BBER 01-04 aus l10n_at_personalgruppe/lehrling, GERF (Kataloggehalt vs. GFG), FRDV, BVAB/BVJN aus dem GP5-BMSVG-Flag, VWAZ (Stunden x100, neu ab 01.01.2026), AGRD aus dem hr.departure.reason-Mapping (Standard 00). Fallweise Anmeldung (BEST MA, SART 32, Version 07, Länge 450): MVP je Anmeldetag am Vertragsbeginn; tagesgenaue Einsatztage via Work-Entry-Auswertung = dokumentierter Rest. NSchAB-Branche als Company-Flag (l10n_at_nschab): B001-T01 + 3,80 % (Schlechtwetterentschädigung, ÖGK-Tarifsystem Normalbetriebe mit Schlechtwetterentschädigung). Tests: test_meldewesen_private (2 — mBGM-Satzhierarchie mit A03 je Basis aus November-Fixture + GSUM-Konsistenz; VR M3/M4 mit Registry-Idempotenz und AGRD). Rechenfälle docs/rechenfaelle/meldewesen.md. 19.0.7.0.0. --- addons/l10n_at_hr_payroll_private/README.md | 36 +- .../__manifest__.py | 16 +- .../data/rule_parameters_mbgm_private.xml | 27 + .../docs/rechenfaelle/meldewesen.md | 112 ++++ .../models/__init__.py | 4 +- .../l10n_at_hr_payroll_private/models/mbgm.py | 506 ++++++++++++++++++ .../models/versichertenmeldung.py | 406 ++++++++++++++ .../security/ir.model.access.csv | 7 +- .../tests/__init__.py | 3 +- .../tests/test_meldewesen_private.py | 183 +++++++ .../views/meldewesen_views.xml | 147 +++++ 11 files changed, 1434 insertions(+), 13 deletions(-) create mode 100644 addons/l10n_at_hr_payroll_private/data/rule_parameters_mbgm_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/docs/rechenfaelle/meldewesen.md create mode 100644 addons/l10n_at_hr_payroll_private/models/mbgm.py create mode 100644 addons/l10n_at_hr_payroll_private/models/versichertenmeldung.py create mode 100644 addons/l10n_at_hr_payroll_private/tests/test_meldewesen_private.py create mode 100644 addons/l10n_at_hr_payroll_private/views/meldewesen_views.xml diff --git a/addons/l10n_at_hr_payroll_private/README.md b/addons/l10n_at_hr_payroll_private/README.md index c3a8a10..3fe442b 100644 --- a/addons/l10n_at_hr_payroll_private/README.md +++ b/addons/l10n_at_hr_payroll_private/README.md @@ -117,19 +117,37 @@ Auszahlung über `hr_payroll_account_iso20022`; kein VRV/MVAG 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). +**GP7 Meldewesen/Belege (umgesetzt 2026-09-12):** auf dem Kern- +Framework (ELDA-Fixlängen-Dateien, ISO-8859-15, CRLF-Annahme; +L16 + Arbeitsstättenmeldung + Belege Lohnzettel AT/Monatsdienstliste/ +Monatsjournal im Kern): **mBGM/JASV** (Paket/mBGM/Tarifblock/Verrech- +nungsbasis/-position, T01-Gesamtsatz aus ``at_mbgm_tarif`` — normativ +39,05 % laufend / 37,55 % SZ; Abschläge A01/A02/A03 § 2a AMPFG **je +Basis AB und SZ**, A09/A10/A15 aus GP4-Stichtagen; BV-Basis + V01 aus +GP5; Storno-R-Sätze mit REFU; Fehlanzeige als Null-Paket; Gering- +fährige/fallweise ohne Tarifparameter = listenweiser Laufabbruch); +**Versichertenmeldung** M3/M4 (deklarative Event-Erkennung über die +Meldungs-Registry — idempotent, kein write-diff; BBER 01–04, GERF, +FRDV, BVAB/BVJN aus GP5, VWAZ; AGRD-Mapping am Abgangsgrund) und +**fallweise Anmeldung** (BEST MA, SART 32; MVP je Anmeldetag); +NSchAB-Branche als Company-Flag (B001-T01 + 3,80 %). + +Rechenfälle `docs/rechenfaelle/meldewesen.md`. + +**Offen (GP8+):** 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 (Option); Arbeiter-Sub- +Konten-Endfixierung (Referenzbuchhaltung); Schwerarbeitsmeldung +(nach ASchV-Datenmodell-Bau, DM-ORG E.22-Struktur liegt bei); +§ 109a (separater Baustein); Mitteilungsfile-Import (GP8); +geringfügig/fallweise-Tarifparameter pflegen (at_mbgm_tarif). Tests: `test_kv_katalog` + `test_entgelt_private` (GP2) + `test_lst_private` (GP3) + `test_sv_private` (GP4) + -`test_bv_private` (GP5) + `test_account_private` (GP6) — +`test_bv_private` (GP5) + `test_account_private` (GP6) + +`test_elda_l16` (GP7 Kern) + `test_meldewesen_private` (GP7) — `.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-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 +**94/94 Tests**.) \ 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 1c18db4..82ade94 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.6.0.0', + 'version': '19.0.7.0.0', 'author': 'ixsol / gem360', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', @@ -65,6 +65,18 @@ Grundlagen: ``personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md`` und (Zahlungsregistrierung/SEPA via ``hr_payroll_account_iso20022``). Auszahlung über ``hr_payroll_account_iso20022``; kein VRV/MVAG (gem360-Brücke). +- **GP7 Meldewesen (dieser Stand):** mBGM/JASV (BEST MB, DM-ORG + 42.7.0: Paket/mBGM/Tarifblock/Verrechnungsbasis/-position mit + GESAMT-Prozentsatz je BSGR aus ``at_mbgm_tarif``, Abschläge + A01/A02/A03 §-2a-AMPFG, A09 UV-60, A10 AV+IE-Entfall, A15 PV- + Halbierung; BV-Basis + V01 aus GP5; Storno-R-Sätze; Fehlanzeige) + + Versichertenmeldung (BEST VR: M3/M4 mit deklarativer Event- + Erkennung über die Meldungs-Registry, AGRD-Mapping am + Abgangsgrund, VWAZ, BBER, GERF; fallweise Anmeldung BEST MA + SART 32) — auf dem GP7-Kern-Framework + (l10n_at_hr_payroll/models/elda.py; L16 + Arbeitsstättenmeldung + + Belege siehe Kern-Modul). NSchAB-Branche als Company-Flag + (B001-T01 + 3,80 %). - **Seed D2 (Referenz-KVs R1):** SI-2203 „Handwerk und Gewerbe" + SI-2748 „Metallgewerbe" (Gehaltstabellen ab 1. 1. 2026, kuratiert aus den KV-Library-Volltexten). @@ -94,9 +106,11 @@ gem360-Brücke). 'data/salary_rules_sv_private.xml', 'data/salary_rules_bvg_private.xml', 'data/hr_payroll_account_private.xml', + 'data/rule_parameters_mbgm_private.xml', 'data/bv_kasse_seed.xml', 'views/kv_views.xml', 'views/bv_views.xml', + 'views/meldewesen_views.xml', 'report/bv_report.xml', ], 'countries': ['at'], diff --git a/addons/l10n_at_hr_payroll_private/data/rule_parameters_mbgm_private.xml b/addons/l10n_at_hr_payroll_private/data/rule_parameters_mbgm_private.xml new file mode 100644 index 0000000..54392a8 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/rule_parameters_mbgm_private.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + mBGM-Tarifsätze je Beschäftigtengruppe (SV-Tarifsystem) + at_mbgm_tarif + + + + + 2026-01-01 + {'B001': {'laufend': 39.05, 'sz': 37.55}, 'B002': {'laufend': 39.05, 'sz': 37.55}} + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/meldewesen.md b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/meldewesen.md new file mode 100644 index 0000000..a22547c --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/meldewesen.md @@ -0,0 +1,112 @@ +# Rechenfälle Meldewesen/Belege (GP7) — ELDA/eSV, L16, Belege + +**Quellen:** DM-ORG 42.7.0 „Datenaustausch mit Dienstgebern", 42. +Ergänzung 07/2026 (`.firecrawl/ris/elda/` — Volltext-PDF + pdftotext; +Satzstrukturen PDF-verifiziert extrahiert 2026-09-12, Positions- +Ketten lückenlos); Pruefkatalog L16 ab 2026 (Stand 12/2025, xlsx); +KB lb-lvr-04/05 (L16), lb-mel-08 (Fristen), lb-bes-02/03 (fallweise), +lb-sva-14 (SZ-Sätze), lb-swa-05 (Schwerarbeit, out); Nutzer- +Entscheidungen 5.1–5.4 (Arbeitsstättenmeldung in GP7; Mitteilungsfile- +Import Rest; **FB+ = keine Personalverrechnung** — bei Steuer < 0 +Arbeitnehmerveranlagung, **Kind-Modell für die FABO+-Wartung**; alles +in einem GP7). Umsetzung: Kern `l10n_at_hr_payroll` 19.0.4.0.0 +(`models/elda.py`, `lohnzettel_l16.py`, `kind.py`, `meldewesen.py`, +`monatsreports.py`, `report/belege_reports.xml`), privat +19.0.7.0.0 (`models/mbgm.py`, `versichertenmeldung.py`, +`data/rule_parameters_mbgm_private.xml`). Tests: `test_elda_l16`, +`test_meldewesen_private`. + +## Systematik (GP7, verifiziert) + +- **ELDA-Fixlängen-Dateien** (C.1/C.2, E.1–E.3): Datei = optionaler + Dateiheader (DHKZ 2×blank, DHVN 01, ENCD **ISO-8859-15**) + Daten- + bestand (Vorlaufsatz SART 00 mit BEST/VSTR/VERS → Datensätze → + Schlusssatz SART 99 mit SANZ inkl. Vorlauf/Schluss); a/n linksbündig + blank-gefüllt, n rechtsbündig null-gefüllt, **Cent ohne + Interpunktion**, Prozentsätze ×1000 (3 Nachkommastellen), SANR + lückenlos ab 1. **CRLF als Satztrenner = Praxis-Annahme** (die + DM-ORG normiert kein Trennzeichen — Parallellauf verifizieren). +- **mBGM/JASV** (BEST `MB`, Version 02; E.32): Hierarchie Paket PS + (BKNR/DGNA/BZRM/GSUM/ANZM) → mBGM G1 (REFW/VSNR/FANA/VONA/VSUM/ + VERG=1) → Tarifblock T1 (BSGR/VVON) → Verrechnungsbasis BS (VBTY/ + VBBT) → Verrechnungsposition V1 (VPTY/VPVZ/VPTA/RSVZ/RSUM). + **KEIN DN/DG-Zweig-Split**: T01 = GESAMT-Prozentsatz des Tarifsystems + (Parameter `at_mbgm_tarif`, normativ: laufend **39,05 %** = + KV 7,65 + PV 22,80 + AV 5,90 + UV 1,10 + WF 1,00 + IESG 0,10; SZ + **37,55 %** = DN KV 3,87 + PV 10,25 + AV 2,95 + DG 20,48). Abschläge + als EIGENE Positionen je Basis: **A01/A02/A03** = Minderung AV auf + 2/1/0 % (§ 2a AMPFG — nur DN-AV, **je Verrechnungsbasis AB UND SZ**; + A03 = −2,95 % der Basis, RSVZ −), A09 = UV-Entfall 60 (−1,10 %), A10 + = AV+IE-Entfall (−6,00 %), A15 = PV-Halbierung (−11,40 %); + Konkurrenz a4: Entfall vor Minderung (A10 sperrt A0x). **BV**: + Basis `BV` (GP5, ungedeckelt) + `V01` 1,53 %. RSUM = Basis × Satz + (mBGM-Tarifierung); Konsistenz gegen GP4-Payslip-Summen als + Prüfwarnung (Toleranz 0,10 €). Storno: R-Satz je stornierter mBGM + (REFU, VSUM wie Original, ohne T/BS/V). Fehlanzeige: Paket ohne + mBGM-Sätze (ANZM 0). Fallweise/Geringfügige ohne Parametrisierung: + Lauf bricht listenweise ab (BSGR B044/B045/B010/B030 — + Tarifsystem-Werte pflegen, keine Erfindung). +- **L16 Lohnzettel Finanz** (BEST `LF`, VSTR `94` BRZ; E.13/E.14): + I1 (Länge 1100, Strukturversion 03) + L1 (Länge **3500**, + Lohnzettelversion 28, zwingend ab 01.03.2026). **KZ 210 = + Bruttobezüge gem. § 25 GESAMT** (laufende + sonstige §-67-Bezüge, + DM-ORG-Feldbeschreibung!) — deshalb ist **KZ 245 = 210 − 215 − 220 + − 230 − 243** die laufende Steuerbemessung und per Konstruktion mit + der GP3-§-66-Kette konsistent. KZ-Ableitung aus den Kern-Flags + (kein paralleles Klassifikationsfeld): 210 = laufend+sonstig § 67 + (ohne Abs-3/6), 215 = `_l10n_at_freibetraege_68` (§-68-Caps je LZP), + 220 = sonstig ohne Abs-3/6 (inkl. 620-€-Freibetrag vor SV), 225 = + SVDN_SZ, 230 = laufende SVDN+AK+WF, 243 = 0 (nicht im Inventar), + 260/BIEL = LST_LFD + LST_SZ. FB+-Felder (BFABO/KFABO/VFABO/FABO + + Kinderblock 15×123 ab Pos. 1501) aus der **Kind-Stammdaten-Wartung** + (Nutzer-Entscheidung: FB+ rechnet nicht im Lohnsteuerkern — ANV). + REFN je AG+Jahr+DV eindeutig; berichtigter Lohnzettel: ARTL 18 + + KORR K + FIND J. +- **Arbeitsstättenmeldung** § 34 Abs 6 ASVG (BEST `AD`, VSTR `ST` + Statistik Austria; E.20, SART 45, Länge 300): je AN Adresse am + 31. 12. aus dem Dienstort-Partner; **GKZ** (Gemeindekennziffer) als + manuelles Partner-Feld — Warnung, wenn fehlt. +- **Versichertenmeldung** (BEST `VR`, Version 03, Länge 772; E.29): + M3 vor Arbeitsantritt (BKNR/VSNR/GEBD/FANA/VONA/ADAT/BBER 01–04/ + GERF/FRDV/BVAB/BVJN aus GP5/VWAZ Stunden×100), M4 7 Tage (ADAT + + EBSV, AGRD aus dem hr.departure.reason-Mapping, Standard 00), + M6 nur Ausnahmefälle; **deklarative Event-Erkennung** über die + Meldungs-Registry (keine write-diff-Hacks; idempotente Läufe). + **Fallweise** (BEST `MA`, SART 32, Länge 450, Version 07): je + Beschäftigungstag vor Arbeitsantritt; MVP: je AN/Vertragsbeginn + ein Anmeldetag (tagesgenaue Einsatztage via Work-Entries = Rest). +- **Belege (AP6)**: Lohnzettel AT (ir.actions.report auf hr.payslip, + AT-Layout mit Bezügen/Abzügen/**DG-Anteilen**/Netto; per Structure + über `report_id`), Monatsdienstliste (mBGM-Kontrollschicht je AN: + BG laufend/SZ, SV-DN/SZ, SV-DG, LSt, Netto) + Monatsjournal (je + Lohnart Summen je Lauf) auf `hr.payslip.run` (QWeb, AbstractModel). + Kein HR-Modul nutzt `account.report` — QWeb ist der framework- + gerechte Weg (Contribution-Register-Muster). + +## Rechenfälle (Fixtures) + +| RF | Konstellation | Erwartung | +|---|---|---| +| RF-ELDA-1 | Feld-Formatter | a/n linksbündig blank; n rechtsbündig 0-gefüllt; Cent kaufmännisch (0,005 → 1); Prozentsatz skala 3 (39,30 → 039300) | +| RF-ELDA-2 | Bestand/Datei | Vorlauf SART 00 (VSTR/BEST/EDAT TTMMJJJJ/VERS), SANR lückenlos, SANZ inkl. Rahmen, CRLF, Dateiheader ` 01ISO-8859-15…`, Umlaute ISO-8859-15-codiert | +| RF-L16-1 | November mit B13 (Jahr 2026) | KZ 210 = 4.118,82 (§ 25 gesamt!), KZ 220 = 2.059,41, KZ 245 = laufende Bemessung > 0; I1 (1.100) mit STNRA/JAHR/GESA; L1 (3.500) exakte Positionen: REFN/ARTL 01/BELZ 0111/SOZS 3/AVLN 1234/AGBD 150590/B210 0000411882; Vorlauf VSTR 94, BEST LF, VERS 28; SANZ 4 | +| RF-L16-2 | Arbeitsstätte | Satz 45 (300): BKNR/VSNR/STNR; VSTR ST, BEST AD; Warnung GKZ fehlt | +| RF-MBGM-1 | November mit B13, Angestellte (B002) | PS (BKNR/BZRM 112026/ANZM 1/GSUM = Σ VSUM), G1 (VSNR), T1 BSGR B002, Positionen T01 039050, **A03 je Basis** (AB + SZ, RSVZ −), T02 037550, V01; GSUM ≈ GP4-Beitragssummen ± 0,10 € | +| RF-VR-1 | Vertrag ab 2020, Ende 15.02.2026 | M3 (ADAT 01012020, BBER 02, GERF N, FRDV N, VWAZ 4000, BVJN J); Registry verhindert Doppelanmeldung; M4 (ADAT 15022026, EBSV, AGRD 00) | + +## Offene Punkte / bewusst nicht in GP7 + +- **Schwerarbeitsmeldung** (DM-ORG E.22 Satzstruktur existiert!): + blockiert — keine ASchV-Work-Entry-Typen/Zähler im Modul, ASchV in + RQ-Privat ❓ nicht erschlossen. Erst nach Datenmodell-Bau. +- **§ 109a** (W1-Satz): separater Baustein (Honorare außerhalb der + Lohnverrechnung); Mitteilungs-XSDs/Prüfkatalog beschafft. +- **L17** (A1-Satz): keine AG-Pflicht. **Mitteilungsfile-Import** + (Rückantwort-Fehler ins Deklarationsmodell): GP8-Rest (5.2). +- Geringfügigen-mBGM (B010/B030-Sätze, Z04-Jahreszahlung + 2,5 %): + parametrisieren, sobai vorkommend; mBGM je Lauf bei Mehrfachläufen + im Monat; M6/Änderung; FB+-Lohnsteuermechanik (ANV-Sache); + VSNR-Prüfziffer-Algorithmus (SVS-Quelle beschaffen); + tagesgenaue fallweise-Einsatztage (Work-Entry-Auswertung). +- Referenzbuchhaltung/Parallellauf: CRLF-Satztrenner, ELDA-Kanäle + (Sendemodul https/ftps 40/220 MB), Fristenkalender (GP8). \ 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 16dd4c5..4a8bbb3 100644 --- a/addons/l10n_at_hr_payroll_private/models/__init__.py +++ b/addons/l10n_at_hr_payroll_private/models/__init__.py @@ -6,4 +6,6 @@ from . import bv_kasse from . import abfertigung_private from . import bv_report from . import account_chart_private -from . import account_posting_private \ No newline at end of file +from . import account_posting_private +from . import mbgm +from . import versichertenmeldung \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/mbgm.py b/addons/l10n_at_hr_payroll_private/models/mbgm.py new file mode 100644 index 0000000..4bcd0a3 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/mbgm.py @@ -0,0 +1,506 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""Monatliche Beitragsgrundlagenmeldung mBGM/JASV (GP7/AP5) — DM-ORG +42.7.0, Kapitel E.32 (BEST ``MB``, mBGM-Version 02; PDF-verifizierte +Extraktion 2026-09-12). + +Hierarchie: Paket (SART ``PS`` Selbstabrechner, Länge 305) → mBGM +(SART ``G1``, 326) → Tarifblock (SART ``T1``, 42) → Verrechnungsbasis +(``BS``, 33) → Verrechnungsposition (``V1``, 42). + +Fachliche Grundlagen: +- **KEIN DN/DG- oder Zweig-Split**: die Standardposition ``T01`` + trägt den GESAMT-Prozentsatz des Tarifsystems (Parameter + ``at_mbgm_tarif`` — BSGR-spezifisch; Werte aus dem ÖGK- + Beitragstarifsystem, `.additional_docs/oegk/`, jeweils + parametrisiert zu pflegen). Fehlende BSGR-Sätze brechen den Lauf + listenweise ab (keine Erfindung von Tarifen). +- **Abschläge als eigene Verrechnungspositionen** (D.60, verifiziert): + A01/A02/A03 = Minderung AV auf 2/1/0 % (§ 2a AMPFG, DN-seitig — + Abschlag = AB × (2,95 − Zielsatz) %), A09 = UV-Entfall ab 60 + (1,10 %), A10 = AV+IE-Entfall Pensionsanspruch (6,00 %), A15 = + PV-Halbierung Bonusphase (11,40 %). A02/A10 schließen einander aus + (Konkurrenz a4: Entfall vor Minderung — wie der GP4-Kern rechnet). +- **BV (BMSVG)**: eigene Verrechnungsbasis ``BV`` (ungedeckelt, GP5) + + Position ``V01`` (1,53 %); SZ-Basis ``SZ`` (§-54-gedeckelt) + + Position ``T02`` mit dem SZ-Gesamtsatz. +- **RSUM aus Basis × Satz** (mBGM-eigene Tarifierung); Konsistenz + gegen die GP4-Payslip-Summen als Prüfwarnung (Toleranz 0,10 €). +- **Fehlanzeige**: Paket ohne mBGM-Sätze (ANZM 0) für Beitragsmonate + ohne meldbare Daten (GP5-Deferral; DM-ORG-Form ⚠ Parallellauf + verifizieren). **Storno**: R1-Sätze mit REFU + identischem BZRM + (VSUM = gestornierte VSUM) für den Nachtragslauf; Nachreichung als + neue Deklaration (GP4-Deferral Aufrollung). +- Fallweise Beschäftigte (G3/G4 mit tagesgenauen Tarifblöcken) und + Geringfügige ohne Tarifparameter: dokumentierter Rest — der Lauf + bricht listenweise ab, bis `at_mbgm_tarif` gepflegt ist. + +Dateibasiert (Prämisse P7): Generierung + Prüfprotokoll; Übertragung +beim Mandanten. Fristen: Selbstabrechner 15., Vorschreibebetriebe 7. +des Folgemonats (lb-mel-08) — Fristenkalender GP8.""" +from datetime import date, timedelta +import base64 + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from odoo.addons.l10n_at_hr_payroll.models.elda import ( + L10N_AT_ELDA_IDTEIL, + L10nAtEldaBestand, + L10nAtEldaDatei, + L10nAtEldaSatz, + l10n_at_euro_cent, +) + +from odoo.addons.l10n_at_hr_payroll.models.lohnzettel_l16 import \ + l10n_at_namen + +# Paket (E.32, Selbstabrechner PS) — nach IDTEIL, Länge 305. +MBGM_PAKET = [ + (2, 'REFP', 21, 40, 'a'), + (3, 'BKNR', 61, 10, 'a'), + (4, 'DGNA', 71, 70, 'a'), + (5, 'MPKE', 141, 30, 'a'), + (6, 'JAGB', 171, 1, 'a'), + (7, 'DTEL', 172, 50, 'a'), + (8, 'MAIL', 222, 60, 'a'), + (9, 'BZRM', 282, 6, 'n'), # MMJJJJ + (10, 'GSVZ', 288, 1, 'a'), + (11, 'GSUM', 289, 11, 'n'), # Σ VSUM (Cent) + (12, 'ANZM', 300, 6, 'n'), # Anzahl mBGM (G/R-Sätze) +] + +# mBGM-Satz (E.32, G1 Selbstabrechner) — nach IDTEIL, Länge 326. +MBGM_SATZ = [ + (2, 'REFW', 21, 40, 'a'), + (3, 'REFU', 61, 40, 'a'), + (4, 'REFV', 101, 40, 'a'), + (5, 'VSNR', 141, 10, 'n'), + (6, 'FANA', 151, 70, 'a'), + (7, 'VONA', 221, 70, 'a'), + (8, 'VSUM', 291, 11, 'n'), # Σ RSUM (Cent) + (9, 'VERG', 302, 1, 'a'), # 1 = SV+BV mit Versicherungszeit + (10, 'INF1', 303, 12, 'a'), + (11, 'INF2', 315, 12, 'a'), +] + +# Tarifblock (E.32, T1) — nach IDTEIL, Länge 42. +MBGM_TARIFBLOCK = [ + (2, 'BSGR', 21, 4, 'a'), # Beschäftigtengruppe (SV-Tarifsystem) + (3, 'ERGB', 25, 15, 'a'), # 5 × 3-stellige Ergänzungen + (4, 'VVON', 40, 2, 'n'), # Beginn der Verrechnung (Tag) + (5, 'KEUE', 42, 1, 'a'), # enthält KE/UE +] + +# Verrechnungsbasis (E.32, BS) — nach IDTEIL, Länge 33. +MBGM_BASIS = [ + (2, 'VBTY', 21, 2, 'a'), # AB/SZ/BV/UU … + (3, 'VBBT', 23, 11, 'n'), # Cent +] + +# Verrechnungsposition (E.32, V1) — nach IDTEIL, Länge 42. +MBGM_POSITION = [ + (2, 'VPTY', 21, 3, 'a'), # T01/T02/V01/A01…A15/Z01 … + (3, 'VPVZ', 24, 1, 'a'), # Vorzeichen Prozentsatz + (4, 'VPTA', 25, 6, 'n'), # Prozentsatz ×1000 (3 Nachkommastellen) + (5, 'RSVZ', 31, 1, 'a'), # Vorzeichen RSUM + (6, 'RSUM', 32, 11, 'n'), # Cent +] + + +class L10nAtPayrollMbgm(models.Model): + _name = 'l10n.at.payroll.mbgm' + _description = 'Monatliche Beitragsgrundlagenmeldung (mBGM/JASV)' + _inherit = ['mail.thread'] + _order = 'beitragsmonat desc, id desc' + + company_id = fields.Many2one( + 'res.company', required=True, + default=lambda self: self.env.company) + beitragsmonat = fields.Date( + required=True, string='Beitragsmonat', + help='Erster Tag des Beitragsmonats (MMJJJJ).') + art = fields.Selection( + [('MB', 'mBGM (regulär)'), + ('ST', 'Storno (R-Sätze, iVm REFU)'), + ('FZ', 'Fehlanzeige (Paket ohne mBGM-Sätze)')], + required=True, default='MB', tracking=True) + storno_id = fields.Many2one( + 'l10n.at.payroll.mbgm', string='Stornierte Deklaration', + help='Art ST: Referenz auf die ursprüngliche Deklaration; ' + 'REFU der R-Sätze = REFW der stornierten mBGM je AN.') + line_ids = fields.One2many( + 'l10n.at.payroll.mbgm.line', 'mbgm_id', string='mBGM-Sätze') + datei = fields.Binary(attachment=True, copy=False) + datei_name = fields.Char(copy=False) + protokoll = fields.Text(string='Prüfprotokoll') + state = fields.Selection( + [('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft', + required=True, tracking=True) + + _sql_constraints = [( + 'mbgm_company_monat_art_uniq', + 'unique(company_id, beitragsmonat, art)', + 'Für diesen Beitragsmonat existiert bereits eine mBGM-' + 'Deklaration dieser Art.')] + + def _l10n_at_slips(self): + self.ensure_one() + erster = self.beitragsmonat + letzter = (date(erster.year, erster.month, 28) + + timedelta(days=4)).replace(day=1) \ + - timedelta(days=1) + return self.env['hr.payslip'].search([ + ('company_id', '=', self.company_id.id), + ('date_from', '>=', erster), + ('date_from', '<=', letzter), + ('state', 'in', ('validated', 'paid')), + ]) + + def _l10n_at_slip(self, employee): + """Der (eindeutige) Lauf des AN im Beitragsmonat.""" + self.ensure_one() + erster = self.beitragsmonat + letzter = (date(erster.year, erster.month, 28) + + timedelta(days=4)).replace(day=1) \ + - timedelta(days=1) + return self.env['hr.payslip'].search([ + ('company_id', '=', self.company_id.id), + ('employee_id', '=', employee.id), + ('date_from', '>=', erster), + ('date_from', '<=', letzter), + ('state', 'in', ('validated', 'paid'))], limit=1) + + def _l10n_at_bsgr(self, version, slip): + """Beschäftigtengruppe (SV-Tarifsystem): B001 Arbeiter, B002 + Angestellte; Lehrlinge B044/B045 und geringfügig/fallweise + (B010/B030) ohne Default-Parameter → Lauf bricht ab, bis + `at_mbgm_tarif` gepflegt ist (Tarifsystem-Werte, keine + Erfindung).""" + if version.l10n_at_lehrling: + return 'B044' if getattr( + version, 'l10n_at_personalgruppe', '') == 'arbeiter' \ + else 'B045' + if slip.l10n_at_geringfuegig or version.l10n_at_fallweise: + return 'B030' + return 'B001' if getattr( + version, 'l10n_at_personalgruppe', '') == 'arbeiter' \ + else 'B002' + + def _l10n_at_tarif(self, bsgr): + self.ensure_one() + tarif = self.env['hr.rule.parameter']._get_parameter_from_code( + 'at_mbgm_tarif', self.beitragsmonat, + raise_if_not_found=False) or {} + return tarif.get(bsgr) + + def _l10n_at_positionen(self, slip, tarif): + """Verrechnungspositionen je Tarifblock: T01 (Gesamtsatz, + NSchAB-Zweig via Company-Flag) + Abschläge aus den GP4- + Stichtagsflags; T02 auf SZ; V01 auf BV-Basis. Rückgabe: + [(vpty, vpta, rsum_cent, basis_typ), …] — RSUM aus Basis × + Satz (mBGM-Tarifierung); Abschläge negativ (RSVZ '-').""" + version = slip.version_id + ab = sum(basis for _m, basis + in slip._l10n_at_sv_basis_laufen_monate()) + nschab = 3.80 if (self.company_id.l10n_at_nschab + and not version.l10n_at_lehrling + and getattr(version, 'l10n_at_personalgruppe', + '') == 'arbeiter') else 0.0 + satz_laufen = float(tarif['laufend']) + nschab + positionen = [('T01', satz_laufen, ab * satz_laufen / 100.0, + 'AB')] + # §-2a-AMPFG: Minderung nur des DN-AV (2,95 %), als eigene + # Abschlagsposition auf JEDER betroffenen Verrechnungsbasis + # (AB und SZ — der Abschlag senkt den Gesamtsatz je Basis; + # DN-seitig, GP4-Konvention). Codes: 0 % → A03, 1 % → A02, + # 2 % → A01 (D.60, verifiziert). + minderung = 0.0 + monat = self.beitragsmonat + if not (version.l10n_at_alv_entfall_ab + and monat >= version.l10n_at_alv_entfall_ab): + staffel = self.env['hr.rule.parameter'] \ + ._get_parameter_from_code( + 'at_sv_av_staffel_ampfg', monat, + raise_if_not_found=False) or {} + for _von, bis, dn_satz in staffel.get('tiers', []): + if ab and ab <= float(bis): + minderung = 2.95 - float(dn_satz) + break + if minderung > 0: + code = ('A03' if minderung >= 2.95 + else 'A02' if abs(minderung - 1.95) < 0.01 + else 'A01') + positionen.append( + (code, 0.0, -ab * minderung / 100.0, 'AB')) + else: + # A10 = Entfall AV+IE (DN-AV + DG-AV + DG-IESG). + positionen.append( + ('A10', 0.0, -ab * 6.00 / 100.0, 'AB')) + if version.l10n_at_uv_entfall_ab \ + and monat >= version.l10n_at_uv_entfall_ab: + positionen.append(('A09', 0.0, -ab * 1.10 / 100.0, 'AB')) + if version.l10n_at_pv_bonusphase_ab \ + and monat >= version.l10n_at_pv_bonusphase_ab: + positionen.append(('A15', 0.0, -ab * 11.40 / 100.0, 'AB')) + sz_basis = slip._l10n_at_svdn_basis_sz() + if sz_basis > 0: + satz_sz = float(tarif['sz']) + positionen.append( + ('T02', satz_sz, sz_basis * satz_sz / 100.0, 'SZ')) + if minderung > 0: + code = ('A03' if minderung >= 2.95 + else 'A02' if abs(minderung - 1.95) < 0.01 + else 'A01') + positionen.append( + (code, 0.0, -sz_basis * minderung / 100.0, 'SZ')) + bv_basis = (slip._at_bv_basis_laufen() + + slip._at_bv_basis_sonst()) + if bv_basis > 0: + positionen.append(('V01', 1.53, bv_basis * 0.0153, 'BV')) + return positionen + + def action_erzeugen(self): + for deklaration in self: + fehler = [] + warnungen = [] + company = deklaration.company_id + bknr = company.l10n_at_beitragskontonummer + if not bknr: + fehler.append('Beitragskontonummer (BKNR) fehlt.') + vstr = (bknr or '')[:2] + deklaration.line_ids.unlink() + datensaetze = [] + summe_gesamt = 0 + anzahl = 0 + if deklaration.art != 'FZ': + slips = deklaration._l10n_at_slips() + nach_an = {} + for slip in slips: + nach_an.setdefault(slip.employee_id, []).append(slip) + for employee, employee_slips in nach_an.items(): + version = employee.version_id + if not (version and version.ssnid): + fehler.append( + 'Mitarbeiter %s ohne Versicherungsnummer.' + % employee.name) + continue + if len(employee_slips) > 1: + fehler.append( + 'Mitarbeiter %s mit mehreren Läufen im ' + 'Beitragsmonat — mBGM je Lauf ist ein ' + 'dokumentierter Rest.' % employee.name) + continue + slip = employee_slips[0] + if deklaration.art == 'ST': + # Storno: R1-Satz je stornierter mBGM mit + # REFU = REFW des Originals, VSUM wie Original + # (E.32.2.8 — ohne T-/BS-/V-Sätze). + original = deklaration.storno_id.line_ids \ + .filtered(lambda l: l.employee_id == employee) + if not original: + fehler.append( + 'Storno %s: keine stornierte mBGM ' + 'am Mitarbeiter gefunden.' % employee.name) + continue + refw = 'MBGM-ST-%d-%s-%d' % ( + company.id, + deklaration.beitragsmonat.strftime('%Y%m'), + employee.id) + storno_satz = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + MBGM_SATZ, 326, + bezeichnung='Storno %s' % employee.name) + storno_satz.setze('SART', 'R1') + storno_satz.setze('REFW', refw) + storno_satz.setze('REFU', original.refw) + storno_satz.setze('VSNR', version.ssnid) + storno_satz.setze('VSUM', abs(original.vsum), + cent=True) + datensaetze.append(storno_satz) + deklaration.env['l10n.at.payroll.mbgm.line']\ + .create({ + 'mbgm_id': deklaration.id, + 'employee_id': employee.id, + 'versicherungsnummer': version.ssnid, + 'bsgr': original.bsgr, + 'refw': refw, + 'vsum': original.vsum, + }) + summe_gesamt += original.vsum + anzahl += 1 + continue + bsgr = deklaration._l10n_at_bsgr(version, slip) + tarif = deklaration._l10n_at_tarif(bsgr) + if not tarif: + fehler.append( + 'Mitarbeiter %s: kein Tarifsatz für BSGR ' + '%s parametrisiert (at_mbgm_tarif, Werte ' + 'aus dem SV-Tarifsystem pflegen).' + % (employee.name, bsgr)) + continue + if slip.l10n_at_geringfuegig \ + or version.l10n_at_fallweise: + fehler.append( + 'Mitarbeiter %s: geringfügige/fallweise ' + 'Beschäftigung in der mBGM ist ein ' + 'dokumentierter Rest (BSGR %s, Tarifsystem ' + 'verifizieren).' % (employee.name, bsgr)) + continue + positionen = deklaration._l10n_at_positionen( + slip, tarif) + vsum = sum(rsum for _c, _s, rsum, _b in positionen) + # Konsistenz gegen die GP4-Payslip-Summen. + ist = (slip._l10n_at_svdn_gesamt() + + slip._l10n_at_svdg_gesamt() + + slip._l10n_at_svdn_sz() + + sum( + slip._l10n_at_svdg_sz(k) + for k in ('kv', 'pv', 'av', 'uv', 'iesg')) + + slip._at_bv_beitrag()) + if abs(vsum - ist) > 0.10: + warnungen.append( + 'Mitarbeiter %s: mBGM-VSUM %.2f weicht um ' + '%.2f von den GP4-Beitragssummen ab ' + '(Tarif-/Rundungsdifferenz prüfen).' + % (employee.name, vsum, vsum - ist)) + deklaration.env['l10n.at.payroll.mbgm.line'].create({ + 'mbgm_id': deklaration.id, + 'employee_id': employee.id, + 'versicherungsnummer': version.ssnid, + 'bsgr': bsgr, + 'refw': 'MBGM-%d-%s-%d' % ( + company.id, + deklaration.beitragsmonat.strftime('%Y%m'), + employee.id), + 'vsum': vsum, + }) + datensaetze.extend( + deklaration._l10n_at_mbgm_datensaetze( + employee, version, bsgr, positionen, vsum)) + summe_gesamt += vsum + anzahl += 1 + paket = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + MBGM_PAKET, 326, + bezeichnung='mBGM-Paket') + paket.setze('SART', 'PS') + paket.setze('VSTR', vstr) + paket.setze('REFP', 'MBGM-%d-%s' % ( + company.id, + deklaration.beitragsmonat.strftime('%Y%m'))) + paket.setze('BKNR', bknr) + paket.setze('DGNA', company.name[:70]) + paket.setze('JAGB', 'N') + paket.setze('BZRM', deklaration.beitragsmonat.strftime( + '%m%Y')) + paket.setze('GSVZ', '+' if summe_gesamt >= 0 else '-') + paket.setze('GSUM', abs(summe_gesamt), cent=True) + paket.setze('ANZM', anzahl) + datensaetze.insert(0, paket) + if fehler: + deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % ( + '\n'.join(fehler), '\n'.join(warnungen) or '—') + raise UserError('mBGM-Lauf mit Fehlern abgebrochen:\n' + + '\n'.join(fehler)) + bestand = L10nAtEldaBestand( + 'MB', vstr, '02', + hersteller={'name': 'odoo-at-payroll', 'kfz': 'A', + 'plz': company.partner_id.zip, + 'ort': company.partner_id.city, + 'strasse': company.partner_id.street}, + datensaetze=datensaetze) + datei = L10nAtEldaDatei([bestand]) + deklaration.write({ + 'datei': base64.b64encode(datei.render()), + 'datei_name': 'mbgm_%s.txt' % ( + deklaration.beitragsmonat.strftime('%Y%m'),), + 'protokoll': 'WARNUNGEN:\n%s' % ( + '\n'.join(warnungen) or '—'), + 'state': 'done', + }) + return True + + def _l10n_at_mbgm_datensaetze(self, employee, version, bsgr, + positionen, vsum): + """G1 + Tarifblock + je Basis (AB/SZ/BV) die Positionen als + eigene Sätze (BS/V folgen hierarchisch dem Tarifblock).""" + self.ensure_one() + slip = self._l10n_at_slip(employee) + mbgm_satz = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + MBGM_SATZ, 326, + bezeichnung='mBGM %s' % employee.name) + mbgm_satz.setze('SART', 'G1') + mbgm_satz.setze('REFW', 'MBGM-%d-%s-%d' % ( + self.company_id.id, + self.beitragsmonat.strftime('%Y%m'), employee.id)) + mbgm_satz.setze('VSNR', version.ssnid) + familienname, vorname = l10n_at_namen(employee) + mbgm_satz.setze('FANA', familienname[:70]) + mbgm_satz.setze('VONA', (vorname or '')[:70]) + mbgm_satz.setze('VSUM', abs(vsum), cent=True) + mbgm_satz.setze('VERG', '1') + tarifblock = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + MBGM_TARIFBLOCK, 326, + bezeichnung='Tarifblock %s' % employee.name) + tarifblock.setze('SART', 'T1') + tarifblock.setze('BSGR', bsgr) + tarifblock.setze('VVON', '1') + saetze = [mbgm_satz, tarifblock] + for code, satz_prozent, rsum, basis_typ in positionen: + basis = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + MBGM_BASIS, 326, + bezeichnung='Basis %s %s' % (basis_typ, + employee.name)) + basis.setze('SART', 'BS') + basis.setze('VBTY', basis_typ) + basis_wert = abs(rsum) * 100.0 / satz_prozent \ + if satz_prozent else 0.0 + if basis_typ == 'AB': + basis_wert = sum( + b for _m, b in + slip._l10n_at_sv_basis_laufen_monate()) + elif basis_typ == 'SZ': + basis_wert = slip._l10n_at_svdn_basis_sz() + else: + basis_wert = slip._at_bv_basis_laufen() \ + + slip._at_bv_basis_sonst() + basis.setze('VBBT', basis_wert, cent=True) + position = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + MBGM_POSITION, 326, + bezeichnung='Position %s %s' % (code, employee.name)) + position.setze('SART', 'V1') + position.setze('VPTY', code) + position.setze('VPTA', satz_prozent, skala=3) + position.setze('RSVZ', '-' if rsum < 0 else '+') + position.setze('RSUM', abs(rsum), cent=True) + saetze.extend([basis, position]) + return saetze + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_at_nschab = fields.Boolean( + string='AT Schlechtwetterentschädigungsbeitrag-Branche', + help='Betrieb mit Schlechtwetterentschädigung (ÖGK-' + 'Tarifsystem „Normalbetriebe mit ' + 'Schlechtwetterentschädigung“): Arbeiter (BSGR B001) ' + 'erhalten im mBGM-T01 den um NSchAB 3,80 % erhöhten ' + 'Gesamtsatz.') + + +class L10nAtPayrollMbgmLine(models.Model): + _name = 'l10n.at.payroll.mbgm.line' + _description = 'mBGM — Satzdaten je AN' + _order = 'employee_name' + mbgm_id = fields.Many2one( + 'l10n.at.payroll.mbgm', required=True, ondelete='cascade', + index=True) + employee_id = fields.Many2one('hr.employee', required=True) + employee_name = fields.Char(related='employee_id.name', store=True, + readonly=True) + versicherungsnummer = fields.Char() + bsgr = fields.Char(string='Beschäftigtengruppe') + refw = fields.Char(string='Referenzwert (REFW)') + vsum = fields.Monetary( + string='VSUM', currency_field='currency_id') + currency_id = fields.Many2one( + 'res.currency', related='mbgm_id.company_id.currency_id') \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/versichertenmeldung.py b/addons/l10n_at_hr_payroll_private/models/versichertenmeldung.py new file mode 100644 index 0000000..f2456fc --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/versichertenmeldung.py @@ -0,0 +1,406 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""Versichertenmeldung reduziert (GP7/AP5) — DM-ORG 42.7.0 Kapitel +E.29 (BEST ``VR``, Version 03, Satzlänge 772; PDF-verifizierte +Feldtabelle 2026-09-12): Anmeldung M3 (vor Arbeitsantritt), Abmeldung +M4 (7 Kalendertage ab PV-Ende), Änderungsmeldung M6 nur für +Ausnahmefälle (Inhaltsänderungen laufen über die mBGM; lb-mel-08). +Fallweise Anmeldung (BEST ``MA``, SART 32/33, Länge 450): je +Beschäftigungstag vor Arbeitsantritt (§ 33 Abs 1a Z 1 ASVG). + +Event-Modell deklarativ (Dimona-auto-Muster, ohne write-diff): +offene Anmeldungen = älteste Version eines Beschäftigungsspannen- +Beginns ohne M3-Eintrag in der Meldungs-Registry; offene Abmeldungen += beendete Versionen ohne M4-Eintrag. Die Registry +(``l10n.at.payroll.vr.event``) protokolliert gesendete Meldungen +(REFW/ADAT), damit erneute Läufe idempotent bleiben. + +Felder: BBER 01 Arbeiter/02 Angestellte(r)/03–04 Lehrling (D.39), +GERF geringfügig (J/N), FRDV freies Dienstverhältnis, ADAT An-/ +Abmeldedatum, EBSV arbeitsrechtliches Ende vs ADAT (UEL/KE-Verlänger +ung der PV sind nicht modelliert → ADAT = contract_date_end), +AGRD eSV-Abmeldegrund (Mapping am hr.departure.reason, Standard 00), +BVAB/BVJN aus dem GP5-BMSVG-Flag, VWAZ vereinbarte Wochenarbeitszeit +(Stunden ×100, neu für Meldedaten ab 01.01.2026). + +Dateibasiert (Prämisse P7); Bestätigungen/Übertragung beim Mandanten.""" +from datetime import date +import base64 + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from odoo.addons.l10n_at_hr_payroll.models.elda import ( + L10N_AT_ELDA_IDTEIL, + L10nAtEldaBestand, + L10nAtEldaDatei, + L10nAtEldaSatz, +) + +from odoo.addons.l10n_at_hr_payroll.models.lohnzettel_l16 import \ + l10n_at_namen + +# Versichertenmeldung (E.29, Version 03) — nach IDTEIL, Länge 772. +VR_SATZ = [ + (2, 'REFW', 21, 40, 'a'), + (3, 'REFU', 61, 40, 'a'), + (4, 'BKNR', 101, 10, 'a'), + (5, 'DGNA', 111, 70, 'a'), + (6, 'DTEL', 181, 50, 'a'), + (7, 'MAIL', 231, 60, 'a'), + (8, 'INF1', 291, 12, 'a'), + (9, 'INF2', 303, 12, 'a'), + (10, 'VSNR', 315, 10, 'n'), + (11, 'GEBD', 325, 8, 'n'), # TTMMJJJJ + (12, 'REFV', 333, 40, 'a'), + (13, 'FANA', 373, 70, 'a'), + (14, 'VONA', 443, 70, 'a'), + (15, 'ADAT', 513, 8, 'n'), # TTMMJJJJ + (16, 'BDAT', 521, 8, 'n'), + (17, 'RDAT', 529, 8, 'n'), + (18, 'BBER', 537, 2, 'a'), # Beschäftigungsbereich (D.39) + (19, 'GERF', 539, 1, 'a'), # geringfügig J/N + (20, 'FRDV', 540, 1, 'a'), # freier Dienstvertrag J/N + (21, 'EBSV', 541, 8, 'n'), # Ende des Beschäftigungsverhältnisses + (22, 'AGRD', 549, 2, 'a'), # Abmeldegrund (D.22) + (23, 'SAGR', 551, 20, 'a'), + (24, 'KEAB', 571, 8, 'n'), + (25, 'KEBI', 579, 8, 'n'), + (26, 'UEAB', 587, 8, 'n'), + (27, 'UEBI', 595, 8, 'n'), + (28, 'BVAB', 603, 8, 'n'), # BV-Beitrag ab + (29, 'BVEN', 611, 8, 'n'), + (30, 'BVJN', 619, 1, 'a'), # Betriebliche Vorsorge J/N + (31, 'UMDA', 620, 8, 'n'), + (32, 'RUMD', 628, 8, 'n'), + (33, 'SOUM', 636, 1, 'a'), + (34, 'ZTUM', 637, 2, 'a'), + (35, 'ZKUM', 639, 10, 'a'), + (36, 'RWUM', 649, 40, 'a'), + (37, 'RUUM', 689, 40, 'a'), + (38, 'BKUM', 729, 40, 'a'), + (39, 'VWAZ', 769, 4, 'n'), # Stunden ×100 +] + +# Fallweise Anmeldung (E.18, Version 07) — nach IDTEIL, Länge 450. +FALLWEISE_SATZ = [ + (2, 'BKNR', 21, 10, 'a'), + (3, 'DGNA', 31, 70, 'a'), + (4, 'VSNR', 101, 10, 'n'), + (5, 'GEBD', 111, 8, 'n'), + (6, 'FANA', 119, 70, 'a'), + (7, 'VONA', 189, 70, 'a'), + (8, 'GESL', 259, 1, 'n'), # 1 männlich/2 weiblich … + (9, 'ADAT', 260, 8, 'n'), # Tag der fallweisen Beschäftigung + (10, 'BKFZ', 268, 3, 'a'), + (11, 'BPLZ', 271, 9, 'a'), + (12, 'BORT', 280, 40, 'a'), + (14, 'WOBD', 321, 12, 'a'), + (15, 'ZOBD', 333, 15, 'a'), + (17, 'REFN', 410, 30, 'a'), + (18, 'VTAZ', 440, 4, 'n'), # Stunden ×100 +] + + +class L10nAtPayrollVrEvent(models.Model): + """Meldungs-Registry: je gesendeter Meldung ein Eintrag (Art, + ADAT, REFW) — Grundlage für die deklarative Event-Erkennung der + offenen An-/Abmeldungen.""" + _name = 'l10n.at.payroll.vr.event' + _description = 'Versichertenmeldung — gesendete Meldungen' + _order = 'adatum, id' + + company_id = fields.Many2one('res.company', required=True, + default=lambda self: self.env.company) + employee_id = fields.Many2one('hr.employee', required=True, + index=True, ondelete='cascade') + version_id = fields.Many2one('hr.version', required=True, + ondelete='cascade') + art = fields.Selection( + [('M3', 'Anmeldung'), ('M4', 'Abmeldung'), ('M6', 'Änderung'), + ('MA', 'Fallweise Anmeldung')], + required=True) + adatum = fields.Date(required=True) + refw = fields.Char(required=True) + + +class L10nAtPayrollVr(models.Model): + _name = 'l10n.at.payroll.vr' + _description = 'Versichertenmeldung (An-/Abmeldung) — ELDA-Lauf' + _inherit = ['mail.thread'] + _order = 'id desc' + + company_id = fields.Many2one( + 'res.company', required=True, + default=lambda self: self.env.company) + stichtag = fields.Date( + default=fields.Date.today, + help='Anmeldungen bis zu diesem Tag (M3 ist eigentlich VOR ' + 'Arbeitsantritt zu übermitteln — der Lauf meldet ' + 'beginnende Beschäftigungsverhältnisse).') + nur_fallweise = fields.Boolean( + string='Nur fallweise Anmeldungen (BEST MA)', + help='Erzeugt ausschließlich Anmeldungen fallweise ' + 'Beschäftigter (je Beschäftigungstag, SART 32).') + datei = fields.Binary(attachment=True, copy=False) + datei_name = fields.Char(copy=False) + protokoll = fields.Text(string='Prüfprotokoll') + state = fields.Selection( + [('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft', + required=True, tracking=True) + event_ids = fields.Many2many( + 'l10n.at.payroll.vr.event', string='Gesendete Meldungen', + copy=False) + + def _l10n_at_offene_anmeldungen(self): + """Älteste aktive Version je AN ohne M3-Eintrag: Beginn eines + Beschäftigungsverhältnisses (früheste contract_date_start).""" + self.ensure_one() + offene = [] + versionen = self.env['hr.version'].search([ + ('company_id', '=', self.company_id.id), + ('contract_date_start', '!=', False), + ('active', '=', True), + ]).with_company(self.company_id) + nach_an = {} + for version in versionen: + nach_an.setdefault(version.employee_id, version) + if version.contract_date_start \ + < nach_an[version.employee_id].contract_date_start: + nach_an[version.employee_id] = version + registry = self.env['l10n.at.payroll.vr.event'].search([ + ('company_id', '=', self.company_id.id), + ('art', 'in', ('M3', 'MA')), + ]) + gesehen = {(e.employee_id.id, e.adatum) for e in registry} + for employee, version in nach_an.items(): + if (employee.id, version.contract_date_start) in gesehen: + continue + if version.contract_date_start > self.stichtag: + continue + offene.append(version) + return sorted(offene, key=lambda v: v.contract_date_start) + + def _l10n_at_offene_abmeldungen(self): + self.ensure_one() + abmeldungen = [] + versionen = self.env['hr.version'].search([ + ('company_id', '=', self.company_id.id), + ('contract_date_end', '!=', False), + ]).with_company(self.company_id) + registry = self.env['l10n.at.payroll.vr.event'].search([ + ('company_id', '=', self.company_id.id), + ('art', '=', 'M4'), + ]) + gesehen = {(e.employee_id.id, e.adatum) for e in registry} + for version in versionen: + if (version.employee_id.id, version.contract_date_end) \ + in gesehen: + continue + abmeldungen.append(version) + return sorted(abmeldungen, key=lambda v: v.contract_date_end) + + def _l10n_at_bber(self, version): + if version.l10n_at_lehrling: + return '03' if getattr( + version, 'l10n_at_personalgruppe', '') == 'arbeiter' \ + else '04' + return '01' if getattr( + version, 'l10n_at_personalgruppe', '') == 'arbeiter' \ + else '02' + + def _l10n_at_geringfuegig(self, version): + """Vorausschauende Geringfügigkeits-Einschätzung zur Anmeldung: + Kataloggehalt/Überzahlung gegen die GFG (§ 5 Abs 2 ASVG); die + endgültige Bewertung läuft monatlich über den GP4-Kern.""" + gfg = float(self.env['hr.rule.parameter'] + ._get_parameter_from_code( + 'at_sv_gfg_monatlich', version.contract_date_start, + raise_if_not_found=False) or 0.0) + gehalt = version.wage \ + or (version.kv_gruppe_id._get_gehalt( + version.erfahrungsstufe, version.contract_date_start) + if getattr(version, 'kv_gruppe_id', False) else 0.0) + ueberzahlung = getattr(version, 'ueberzahlung', 0.0) or 0.0 + if ueberzahlung: + gehalt = ueberzahlung + return bool(gfg and gehalt and gehalt <= gfg) + + def action_erzeugen(self): + for lauf in self: + fehler = [] + company = lauf.company_id + bknr = company.l10n_at_beitragskontonummer + if not bknr: + raise UserError('Beitragskontonummer (BKNR) fehlt.') + datensaetze = [] + ereignisse = [] + vstr = bknr[:2] + if lauf.nur_fallweise: + versionen = self.env['hr.version'].search([ + ('company_id', '=', company.id), + ('l10n_at_fallweise', '=', True), + ('active', '=', True), + ]) + registry = self.env['l10n.at.payroll.vr.event'].search([ + ('company_id', '=', company.id), + ('art', '=', 'MA'), + ]) + gesehen = {(e.employee_id.id, e.adatum) + for e in registry} + for version in versionen: + if not version.ssnid: + fehler.append('%s ohne Versicherungsnummer.' + % version.employee_id.name) + continue + # MVP: je AN ein Anmeldetag (der Vertragsbeginn); + # tagesgenaue Einsatztage = documented Rest + # (Work-Entry-Auswertung). + adat = version.contract_date_start + if not adat or (version.employee_id.id, adat) \ + in gesehen: + continue + satz = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + FALLWEISE_SATZ, 450, + bezeichnung='Fallweise %s' + % version.employee_id.name) + satz.setze('SART', '32') + satz.setze('VSTR', vstr) + satz.setze('BKNR', bknr) + satz.setze('DGNA', company.name[:70]) + satz.setze('VSNR', version.ssnid) + satz.setze_datum('GEBD', version.employee_id.birthday) + satz.setze('FANA', (version.private_last_name + or '')[:70]) + satz.setze('VONA', (version.private_first_name + or '')[:70]) + satz.setze('GESL', '2' + if version.sex == 'female' else '1') + satz.setze_datum('ADAT', adat) + kalender = version.resource_calendar_id + if kalender: + satz.setze('VTAZ', kalender.hours_per_day, + skala=2) + refn = 'MA-%d-%s-%d' % ( + company.id, adat.strftime('%Y%m%d'), + version.employee_id.id) + satz.setze('REFN', refn) + datensaetze.append(satz) + ereignisse.append((version, 'MA', adat, refn)) + else: + for version in lauf._l10n_at_offene_anmeldungen(): + if not version.ssnid: + fehler.append( + 'Anmeldung %s ohne Versicherungsnummer.' + % version.employee_id.name) + continue + satz = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + VR_SATZ, 772, + bezeichnung='M3 %s' % version.employee_id.name) + refw = 'M3-%d-%s-%d' % ( + company.id, + version.contract_date_start.strftime('%Y%m%d'), + version.employee_id.id) + satz.setze('SART', 'M3') + satz.setze('VSTR', vstr) + satz.setze('REFW', refw) + satz.setze('BKNR', bknr) + satz.setze('DGNA', company.name[:70]) + satz.setze('VSNR', version.ssnid) + satz.setze_datum( + 'GEBD', version.employee_id.birthday) + familienname, vorname = l10n_at_namen( + version.employee_id) + satz.setze('FANA', familienname[:70]) + satz.setze('VONA', (vorname or '')[:70]) + satz.setze_datum('ADAT', version.contract_date_start) + satz.setze('BBER', lauf._l10n_at_bber(version)) + satz.setze('GERF', 'J' if lauf + ._l10n_at_geringfuegig(version) else 'N') + satz.setze('FRDV', 'N') + if getattr(version, 'l10n_at_abfertigung_neu', + False): + satz.setze('BVAB', + version.contract_date_start + .strftime('%d%m%Y')) + satz.setze('BVJN', 'J') + kalender = version.resource_calendar_id + if kalender: + satz.setze('VWAZ', kalender.hours_per_week, + skala=2) + datensaetze.append(satz) + ereignisse.append((version, 'M3', + version.contract_date_start, + refw)) + for version in lauf._l10n_at_offene_abmeldungen(): + if not version.ssnid: + fehler.append( + 'Abmeldung %s ohne Versicherungsnummer.' + % version.employee_id.name) + continue + satz = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + VR_SATZ, 772, + bezeichnung='M4 %s' % version.employee_id.name) + refw = 'M4-%d-%s-%d' % ( + company.id, + version.contract_date_end.strftime('%Y%m%d'), + version.employee_id.id) + agrd = getattr( + version.departure_reason_id, 'l10n_at_agrd', + False) or '00' + satz.setze('SART', 'M4') + satz.setze('VSTR', vstr) + satz.setze('REFW', refw) + satz.setze('BKNR', bknr) + satz.setze('DGNA', company.name[:70]) + satz.setze('VSNR', version.ssnid) + satz.setze_datum( + 'GEBD', version.employee_id.birthday) + familienname, vorname = l10n_at_namen( + version.employee_id) + satz.setze('FANA', familienname[:70]) + satz.setze('VONA', (vorname or '')[:70]) + satz.setze_datum('ADAT', version.contract_date_end) + satz.setze_datum('EBSV', version.contract_date_end) + satz.setze('AGRD', agrd) + datensaetze.append(satz) + ereignisse.append((version, 'M4', + version.contract_date_end, + refw)) + if fehler: + lauf.protokoll = 'FEHLER:\n' + '\n'.join(fehler) + raise UserError('Versichertenmeldung mit Fehlern ' + 'abgebrochen:\n' + '\n'.join(fehler)) + if not datensaetze: + raise UserError('Keine offenen Meldungen zum ' + 'Stichtag %s.' % lauf.stichtag) + bestand = L10nAtEldaBestand( + 'MA' if lauf.nur_fallweise else 'VR', vstr, '07' + if lauf.nur_fallweise else '03', + hersteller={'name': 'odoo-at-payroll', 'kfz': 'A', + 'plz': company.partner_id.zip, + 'ort': company.partner_id.city, + 'strasse': company.partner_id.street}, + datensaetze=datensaetze) + datei = L10nAtEldaDatei([bestand]) + ereignis_werte = [] + for version, art, adatum, refw in ereignisse: + ereignis_werte.append({ + 'company_id': company.id, + 'employee_id': version.employee_id.id, + 'version_id': version.id, + 'art': art, + 'adatum': adatum, + 'refw': refw, + }) + ereignisse = self.env['l10n.at.payroll.vr.event'].create( + ereignis_werte) if ereignis_werte else \ + self.env['l10n.at.payroll.vr.event'] + lauf.write({ + 'datei': base64.b64encode(datei.render()), + 'datei_name': 'versichertenmeldung_%s.txt' % ( + fields.Date.today().strftime('%Y%m%d'),), + 'event_ids': [(6, 0, ereignisse.ids)], + 'state': 'done', + 'protokoll': 'Meldungen: %d' % len(datensaetze), + }) + return True \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv b/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv index a26dac8..c435f32 100644 --- a/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv +++ b/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv @@ -11,4 +11,9 @@ access_l10n_at_payroll_kv_import_wizard,L10n AT Payroll KV-Import Wizard,model_l 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 access_l10n_at_bv_kasse_user,L10n AT BV-Kasse User,model_l10n_at_bv_kasse,hr_payroll.group_hr_payroll_user,1,0,0,0 access_l10n_at_bv_kasse_manager,L10n AT BV-Kasse Manager,model_l10n_at_bv_kasse,hr_payroll.group_hr_payroll_manager,1,1,1,1 -access_l10n_at_bv_endabrechnung_wizard,L10n AT BV-Endabrechnung Wizard,model_l10n_at_bv_endabrechnung_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1 \ No newline at end of file +access_l10n_at_bv_endabrechnung_wizard,L10n AT BV-Endabrechnung Wizard,model_l10n_at_bv_endabrechnung_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1 +access_l10n_at_payroll_mbgm_user,L10n AT Payroll mBGM User,model_l10n_at_payroll_mbgm,hr_payroll.group_hr_payroll_user,1,1,1,0 +access_l10n_at_payroll_mbgm_manager,L10n AT Payroll mBGM Manager,model_l10n_at_payroll_mbgm,hr_payroll.group_hr_payroll_manager,1,1,1,1 +access_l10n_at_payroll_mbgm_line_user,L10n AT Payroll mBGM-Line User,model_l10n_at_payroll_mbgm_line,hr_payroll.group_hr_payroll_user,1,1,1,1 +access_l10n_at_payroll_vr_user,L10n AT Payroll VR User,model_l10n_at_payroll_vr,hr_payroll.group_hr_payroll_user,1,1,1,1 +access_l10n_at_payroll_vr_event_user,L10n AT Payroll VR-Event User,model_l10n_at_payroll_vr_event,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 index b20f7a2..efaaaff 100644 --- a/addons/l10n_at_hr_payroll_private/tests/__init__.py +++ b/addons/l10n_at_hr_payroll_private/tests/__init__.py @@ -4,4 +4,5 @@ from . import test_entgelt_private from . import test_lst_private from . import test_sv_private from . import test_bv_private -from . import test_account_private \ No newline at end of file +from . import test_account_private +from . import test_meldewesen_private \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/tests/test_meldewesen_private.py b/addons/l10n_at_hr_payroll_private/tests/test_meldewesen_private.py new file mode 100644 index 0000000..456d4b0 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_meldewesen_private.py @@ -0,0 +1,183 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""GP7 privat: mBGM (Paket/mBGM/Tarifblock/Basis/Position-Hierarchie +aus GP4-Beitragssummen, T01-Gesamtsatz aus at_mbgm_tarif, Abschläge +A01/A02/A03 §-2a-AMPFG, A09/A10/A15, BV-Basis V01) und +Versichertenmeldung M3/M4 (deklarative Event-Erkennung, +Meldungs-Registry, AGRD).""" +import base64 +import datetime + +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class MeldewesenPrivateTest(TransactionCase): + + def setUp(self): + super().setUp() + self.company = self.env.company + self.company.write({ + 'l10n_at_beitragskontonummer': '1412345678', + 'l10n_at_lohnsteuernummer': '123456789', + }) + 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', + '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.company.resource_calendar_id = self.calendar + employee = self.env['hr.employee'].create({ + 'name': 'mBGM Test', 'birthday': '1990-05-15'}) + version = self.env['hr.version'].search( + [('employee_id', '=', employee.id)], limit=1) + version.write({ + 'contract_date_start': datetime.date(2020, 1, 1), + 'ssnid': '1234150590', + 'sex': 'male', + 'kv_id': self.kv2203.id, + 'kv_gruppe_id': self.gruppe_ii.id, + 'erfahrungsstufe': 1, + 'resource_calendar_id': self.calendar.id, + }) + self.employee = employee + self.version = version + + def _slip(self, monat, jahr=2026, confirm=True): + erster = datetime.date(jahr, monat, 1) + letzter = (erster.replace(day=28) + datetime.timedelta(days=4)) \ + .replace(day=1) - datetime.timedelta(days=1) + slip = self.env['hr.payslip'].create({ + 'name': 'mBGM %d/%d' % (monat, jahr), + 'employee_id': self.employee.id, + 'struct_id': self.structure.id, + 'date_from': erster, + 'date_to': letzter, + }) + 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 test_01_mbgm_november(self): + """November mit B13 (KV-Katalog 2.059,41): Paket PS mit + BZRM/GSUM/ANZM, mBGM G1 (VSNR, VSUM), Tarifblock T1 (BSGR + B002), Basen AB/SZ/BV mit Positionen T01 (39,30 % Gesamtsatz), + T02 (34,60 %), V01 (1,53 %) — Konsistenz gegen die + GP4-Beitragssummen.""" + slip = self._slip(11) + deklaration = self.env['l10n.at.payroll.mbgm'].create({ + 'company_id': self.company.id, + 'beitragsmonat': datetime.date(2026, 11, 1), + 'art': 'MB', + }) + deklaration.action_erzeugen() + self.assertEqual(deklaration.state, 'done') + self.assertEqual(len(deklaration.line_ids), 1) + self.assertEqual(deklaration.line_ids.bsgr, 'B002') + roh = base64.b64decode(deklaration.datei).decode('ISO-8859-15') + zeilen = roh.strip('\r\n').split('\r\n') + # Dateiheader + Vorlauf + PS + G1 + T1 + 5×(BS+V1) + # (T01, A03, T02, A03-SZ, V01 BV) + Schluss: 13 Daten-Sätze + # + 3 Rahmen-Zeilen = 16. + self.assertEqual(len(zeilen), 16) + vorlauf = zeilen[1] + self.assertEqual(vorlauf[0:2], '00') + self.assertEqual(vorlauf[18:20], '14') + self.assertEqual(vorlauf[22:24], 'MB') + daten = zeilen[2:-1] + paket = daten[0] + self.assertEqual(paket[0:2], 'PS') + self.assertEqual(paket[60:70], '1412345678') # BKNR + self.assertEqual(paket[281:287], '112026') # BZRM MMJJJJ + self.assertEqual(paket[299:305], '000001') # ANZM + gsum = int(paket[288:299]) + ist = (slip._l10n_at_svdn_gesamt() + + slip._l10n_at_svdg_gesamt() + + slip._l10n_at_svdn_sz() + + sum(slip._l10n_at_svdg_sz(k) for k in + ('kv', 'pv', 'av', 'uv', 'iesg')) + + slip._at_bv_beitrag()) + self.assertLessEqual(abs(gsum / 100.0 - ist), 0.10) + satzarten = [zeile[0:2] for zeile in daten] + self.assertEqual(satzarten, ['PS', 'G1', 'T1', 'BS', + 'V1', 'BS', 'V1', 'BS', 'V1', + 'BS', 'V1', 'BS', 'V1']) + g1 = daten[1] + self.assertEqual(g1[140:150], '1234150590') + t1 = daten[2] + self.assertEqual(t1[20:24], 'B002') + v1_t01 = daten[4] + self.assertEqual(v1_t01[20:23], 'T01') + self.assertEqual(v1_t01[24:30], '039050') # 39,05 % + # §-2a-AMPFG-Abschlag A03 (BG 2.059,41 ≤ 2.225 → AV 0 %), + # auf AB und SZ je als eigene Position. + v1_a03 = daten[6] + self.assertEqual(v1_a03[20:23], 'A03') + self.assertEqual(v1_a03[30:31], '-') # RSVZ negativ + v1_t02 = daten[8] + self.assertEqual(v1_t02[20:23], 'T02') + self.assertEqual(v1_t02[24:30], '037550') # 37,55 % + v1_a03_sz = daten[10] + self.assertEqual(v1_a03_sz[20:23], 'A03') + + def test_02_vr_an_abmeldung(self): + """M3 (vor Arbeitsantritt) mit BBER/GERF/BVJN/VWAZ und M4 + (7-Tage-Frist) mit AGRD aus dem Abgangsgrund-Mapping; die + Registry verhindert Doppelanmeldungen.""" + lauf = self.env['l10n.at.payroll.vr'].create({ + 'company_id': self.company.id, + 'stichtag': datetime.date(2026, 1, 31), + }) + lauf.action_erzeugen() + roh = base64.b64decode(lauf.datei).decode('ISO-8859-15') + zeilen = roh.strip('\r\n').split('\r\n') + m3 = [z for z in zeilen if z[0:2] == 'M3'] + self.assertEqual(len(m3), 1) + self.assertEqual(m3[0][100:110], '1412345678') # BKNR + self.assertEqual(m3[0][314:324], '1234150590') # VSNR + self.assertEqual(m3[0][324:332], '15051990') # GEBD TTMMJJJJ + self.assertEqual(m3[0][512:520], '01012020') # ADAT + self.assertEqual(m3[0][536:538], '02') # BBER Angestellte + self.assertEqual(m3[0][538:539], 'N') # GERF + self.assertEqual(m3[0][539:540], 'N') # FRDV + self.assertEqual(m3[0][768:772], '4000') # VWAZ 40 h + self.assertEqual(m3[0][618:619], 'J') # BVJN (GP5) + # Registry verhindert Doppelanmeldung; Abmeldung nach Ende. + zweiter = self.env['l10n.at.payroll.vr'].create({ + 'company_id': self.company.id, + 'stichtag': datetime.date(2026, 2, 28), + }) + with self.assertRaises(UserError): + zweiter.action_erzeugen() + self.version.write({ + 'contract_date_end': datetime.date(2026, 2, 15), + 'departure_reason_id': self.env['hr.departure.reason'] + .search([], limit=1).id, + }) + dritter = self.env['l10n.at.payroll.vr'].create({ + 'company_id': self.company.id, + 'stichtag': datetime.date(2026, 3, 31), + }) + dritter.action_erzeugen() + roh = base64.b64decode(dritter.datei).decode('ISO-8859-15') + m4 = [z for z in roh.strip('\r\n').split('\r\n') + if z[0:2] == 'M4'] + self.assertEqual(len(m4), 1) + self.assertEqual(m4[0][512:520], '15022026') # ADAT + self.assertEqual(m4[0][548:550], '00') # AGRD Default \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/views/meldewesen_views.xml b/addons/l10n_at_hr_payroll_private/views/meldewesen_views.xml new file mode 100644 index 0000000..73d2be5 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/views/meldewesen_views.xml @@ -0,0 +1,147 @@ + + + + + + + + l10n.at.payroll.mbgm.list + l10n.at.payroll.mbgm + + + + + + + + + + + l10n.at.payroll.mbgm.form + l10n.at.payroll.mbgm + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + mBGM (JASV) + l10n.at.payroll.mbgm + list,form + + + + l10n.at.payroll.vr.form + l10n.at.payroll.vr + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Versichertenmeldung (An-/Abmeldung) + l10n.at.payroll.vr + form + new + + + + + + + +
\ No newline at end of file