[IMP] l10n_at_hr_payroll_private: GP7 mBGM/JASV + Versichertenmeldung + fallweise Anmeldung auf dem ELDA-Framework

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.
This commit is contained in:
2026-09-12 17:31:12 +02:00
parent c474a39466
commit 14940ac31a
11 changed files with 1434 additions and 13 deletions
+27 -9
View File
@@ -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 0104, 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 <testdb> -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.)
**94/94 Tests**.)
@@ -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'],
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- GP7: mBGM-Tarifsätze (Standardposition T01/T02 = GESAMT- -->
<!-- Prozentsatz je Beschäftigtengruppe aus dem ÖGK- -->
<!-- Beitragstarifsystem „Normalbetriebe Allgemeiner Teil", -->
<!-- gültig ab 01.07.2026 (.additional_docs/oegk/, Primär- -->
<!-- quelle GP4). NORMATIVE Sätze (Abschläge wie A03 laufen als -->
<!-- eigene Verrechnungspositionen): laufend = DN 18,07 + DG -->
<!-- 20,98 = 39,30 AK/… exakt: KV 7,65 + PV 22,80 + AV 5,90 + -->
<!-- UV 1,10 + WF 1,00 + IESG 0,10 = 39,05 %; SZ = KV 3,87 + -->
<!-- PV 10,25 + AV 2,95 (DN; § 67 Abs 12) + DG 20,48 (KV 3,78 + -->
<!-- PV 12,55 + AV 2,95 + UV 1,10 + IESG 0,10) = 37,55 %. -->
<!-- NSchAB-Betriebe: + 3,80 % auf B001 laufend über das Company- -->
<!-- Flag l10n_at_nschab. Lehrlinge (B044/B045) und Geringfügige -->
<!-- (B010/B030) bewusst NICHT geseedet — Werte aus dem SV- -->
<!-- Tarifsystem pflegen, sobai solche Beschäftigten vorkommen. -->
<record id="hr_rule_parameter_at_mbgm_tarif" model="hr.rule.parameter">
<field name="name">mBGM-Tarifsätze je Beschäftigtengruppe (SV-Tarifsystem)</field>
<field name="code">at_mbgm_tarif</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_mbgm_tarif_2026" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_mbgm_tarif"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">{'B001': {'laufend': 39.05, 'sz': 37.55}, 'B002': {'laufend': 39.05, 'sz': 37.55}}</field>
</record>
</odoo>
@@ -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.15.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.1E.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 0104/
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).
@@ -7,3 +7,5 @@ from . import abfertigung_private
from . import bv_report
from . import account_chart_private
from . import account_posting_private
from . import mbgm
from . import versichertenmeldung
@@ -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')
@@ -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)/0304 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
@@ -12,3 +12,8 @@ access_l10n_at_payroll_kv_import_line,L10n AT Payroll KV-Import Line,model_l10n_
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
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
@@ -5,3 +5,4 @@ from . import test_lst_private
from . import test_sv_private
from . import test_bv_private
from . import test_account_private
from . import test_meldewesen_private
@@ -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
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP7 Meldewesen (privat): mBGM + Versichertenmeldung. -->
<!-- ============================================================ -->
<record id="mbgm_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.mbgm.list</field>
<field name="model">l10n.at.payroll.mbgm</field>
<field name="arch" type="xml">
<list string="mBGM (JASV)">
<field name="beitragsmonat"/>
<field name="art"/>
<field name="state" widget="badge"
decoration-success="state == 'done'"
decoration-info="state == 'draft'"/>
</list>
</field>
</record>
<record id="mbgm_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.mbgm.form</field>
<field name="model">l10n.at.payroll.mbgm</field>
<field name="arch" type="xml">
<form string="Monatliche Beitragsgrundlagenmeldung (mBGM)">
<header>
<button name="action_erzeugen" type="object"
string="Lauf erzeugen (Datei)"
class="btn-primary"
invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<sheet>
<group>
<group>
<field name="beitragsmonat"/>
<field name="art"/>
<field name="storno_id"
invisible="art != 'ST'"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="mBGM-Sätze" name="linien">
<field name="line_ids">
<list editable="bottom">
<field name="employee_name"/>
<field name="versicherungsnummer"/>
<field name="bsgr"/>
<field name="refw"/>
<field name="vsum"/>
</list>
</field>
</page>
<page string="Datei &amp; Protokoll" name="datei">
<group>
<field name="datei"
filename="datei_name"/>
<field name="datei_name" invisible="1"/>
</group>
<field name="protokoll"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_mbgm" model="ir.actions.act_window">
<field name="name">mBGM (JASV)</field>
<field name="res_model">l10n.at.payroll.mbgm</field>
<field name="view_mode">list,form</field>
</record>
<record id="vr_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.vr.form</field>
<field name="model">l10n.at.payroll.vr</field>
<field name="arch" type="xml">
<form string="Versichertenmeldung (An-/Abmeldung)">
<header>
<button name="action_erzeugen" type="object"
string="Meldungen erzeugen (Datei)"
class="btn-primary"
invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<sheet>
<group>
<group>
<field name="stichtag"/>
<field name="nur_fallweise"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Datei &amp; Protokoll" name="datei">
<group>
<field name="datei"
filename="datei_name"/>
<field name="datei_name" invisible="1"/>
</group>
<field name="protokoll"/>
<field name="event_ids">
<list>
<field name="employee_id"/>
<field name="art"/>
<field name="adatum"/>
<field name="refw"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_vr" model="ir.actions.act_window">
<field name="name">Versichertenmeldung (An-/Abmeldung)</field>
<field name="res_model">l10n.at.payroll.vr</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<menuitem id="menu_l10n_at_payroll_private_meldewesen"
name="Meldewesen (ELDA/eSV)"
parent="l10n_at_hr_payroll_private.menu_l10n_at_payroll_private_root"
sequence="70"/>
<menuitem id="menu_l10n_at_payroll_private_mbgm"
name="mBGM (JASV)"
parent="menu_l10n_at_payroll_private_meldewesen"
action="action_mbgm" sequence="10"/>
<menuitem id="menu_l10n_at_payroll_private_vr"
name="Versichertenmeldung"
parent="menu_l10n_at_payroll_private_meldewesen"
action="action_vr" sequence="20"/>
</odoo>