[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:
@@ -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 <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.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).
|
||||
@@ -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
|
||||
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)/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
|
||||
@@ -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
|
||||
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
|
||||
|
@@ -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
|
||||
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 & 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 & 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>
|
||||
Reference in New Issue
Block a user